From 87dfb25c37540c226cb20cd752498c62fed2d5ce Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman Date: Sun, 30 Apr 2017 13:29:10 -0700 Subject: [PATCH 01/42] Add a fancy show rounds page complete with cutoffs, time limits, and advancing to next round requirements. Part of #1500. --- .../controllers/competitions_controller.rb | 8 ++- WcaOnRails/app/models/competition_event.rb | 4 ++ .../views/competitions/show_events.html.erb | 68 +++++++++++++++++++ WcaOnRails/config/routes.rb | 1 + .../competitions/show_events.html.erb_spec.rb | 25 +++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 WcaOnRails/app/views/competitions/show_events.html.erb create mode 100644 WcaOnRails/spec/views/competitions/show_events.html.erb_spec.rb diff --git a/WcaOnRails/app/controllers/competitions_controller.rb b/WcaOnRails/app/controllers/competitions_controller.rb index 0af4aca70c..053ecb30f6 100644 --- a/WcaOnRails/app/controllers/competitions_controller.rb +++ b/WcaOnRails/app/controllers/competitions_controller.rb @@ -317,12 +317,16 @@ def post_results end end + def show_events + @competition = competition_from_params(includes: [competition_events: { rounds: [:format, :competition_event] }]) + end + def edit_events - @competition = competition_from_params(includes: [:events]) + @competition = competition_from_params end def update_events - @competition = competition_from_params(includes: [:events]) + @competition = competition_from_params if @competition.update_attributes(competition_params) flash[:success] = t('.update_success') redirect_to edit_events_path(@competition) diff --git a/WcaOnRails/app/models/competition_event.rb b/WcaOnRails/app/models/competition_event.rb index 34bc092bbf..e0958b155f 100644 --- a/WcaOnRails/app/models/competition_event.rb +++ b/WcaOnRails/app/models/competition_event.rb @@ -28,6 +28,10 @@ def has_fee? fee.nonzero? end + def event + Event.c_find(event_id) + end + def to_wcif { "id" => self.event.id, diff --git a/WcaOnRails/app/views/competitions/show_events.html.erb b/WcaOnRails/app/views/competitions/show_events.html.erb new file mode 100644 index 0000000000..e42b862e86 --- /dev/null +++ b/WcaOnRails/app/views/competitions/show_events.html.erb @@ -0,0 +1,68 @@ +<% provide(:title, @competition.name) %> + +<%= render layout: 'nav' do %> + <% cumulative_time_limit = false %> + <% cumulative_across_rounds_time_limit = false %> + <%= wca_table do %> + + + <% max_round_number = @competition.competition_events.map { |ce| ce.rounds.length }.max %> + Event name + <% (1..max_round_number).each do |round_number| %> + Round <%= round_number %> + <% end %> + + + <% (1..max_round_number).each do |round_number| %> + Format + <%= link_to "Time Limit", "#time-limit" %> + <%= link_to "Cutoff", "#cutoff" %> + + <% end %> + + + + + <% @competition.competition_events.each do |competition_event| %> + + + <%= competition_event.event.name %> + + <% competition_event.rounds.each do |round| %> + <%= round.format.name %> + + <%= round.time_limit_to_s %> + <% if round.time_limit.cumulative_round_ids %> + <% if round.time_limit.cumulative_round_ids.length == 1 %> + <% cumulative_time_limit = true %> + <%= link_to "*", "#cumulative-time-limit" %> + <% else %> + <% cumulative_across_rounds_time_limit = true %> + <%= link_to "**", "#cumulative-across-rounds-time-limit" %> + <% end %> + <% end %> + + <%= round.cutoff_to_s %> + <%= round.advancement_condition_to_s %> + <% end %> + + <% end %> + + <% end %> + +
+
Time Limit
+
+ AKA: hard cutoff. + <% if cumulative_time_limit %> + A cumulative time limit may be enforced.... + <% end %> + <% if cumulative_across_rounds_time_limit %> + A cumulative time limit may be enforced across rounds.... + <% end %> +
+ +
Cutoff
+
What is a cutoff?
+
+<% end %> diff --git a/WcaOnRails/config/routes.rb b/WcaOnRails/config/routes.rb index c8a2f48a49..42b2a10826 100644 --- a/WcaOnRails/config/routes.rb +++ b/WcaOnRails/config/routes.rb @@ -57,6 +57,7 @@ get 'competitions/:id/payment_setup' => 'competitions#payment_setup', as: :competitions_payment_setup get 'stripe-connect' => 'competitions#stripe_connect', as: :competitions_stripe_connect get 'competitions/:id/events/edit' => 'competitions#edit_events', as: :edit_events + get 'competitions/:id/events' => 'competitions#show_events', as: :show_events patch 'competitions/:id/events' => 'competitions#update_events', as: :update_events patch 'competitions/:id/wcif/events' => 'competitions#update_events_from_wcif', as: :update_events_from_wcif get 'competitions/edit/nearby_competitions' => 'competitions#nearby_competitions', as: :nearby_competitions diff --git a/WcaOnRails/spec/views/competitions/show_events.html.erb_spec.rb b/WcaOnRails/spec/views/competitions/show_events.html.erb_spec.rb new file mode 100644 index 0000000000..e9d3326855 --- /dev/null +++ b/WcaOnRails/spec/views/competitions/show_events.html.erb_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "competitions/show_events" do + let(:competition) { FactoryGirl.create(:competition, :visible, event_ids: %w(333 444)) } + let(:sixty_second_2_attempt_cutoff) { Cutoff.new(number_of_attempts: 2, attempt_result: 1.minute.in_centiseconds) } + let(:top_16_advance) { RankingCondition.new(16) } + let!(:round333_1) { FactoryGirl.create(:round, competition: competition, event_id: "333", number: 1, cutoff: sixty_second_2_attempt_cutoff, advancement_condition: top_16_advance) } + let!(:round333_2) { FactoryGirl.create(:round, competition: competition, event_id: "333", number: 2) } + let!(:round444_1) { FactoryGirl.create(:round, competition: competition, event_id: "444", number: 1) } + before :each do + # Load all the rounds we just created. + competition.reload + end + + before do + assign(:competition, competition) + end + + it "renders advancment condition for 333 round 1" do + render + expect(rendered).to match 'Top 16 advance to round 2' + end +end From 972b4e5d5a12bc16c497607dad76a9afd4d9110e Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman Date: Thu, 18 May 2017 15:50:25 -0700 Subject: [PATCH 02/42] Add serializable_hash methods for the javascript infrastructure we're going to need. --- WcaOnRails/app/models/event.rb | 8 ++++++++ WcaOnRails/app/models/format.rb | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/WcaOnRails/app/models/event.rb b/WcaOnRails/app/models/event.rb index a1599a9236..f5eda1b3b7 100644 --- a/WcaOnRails/app/models/event.rb +++ b/WcaOnRails/app/models/event.rb @@ -54,4 +54,12 @@ def fewest_moves? def multiple_blindfolded? self.id == "333mbf" || self.id == "333mbo" end + + def serializable_hash(options = nil) + { + id: self.id, + name: self.name, + format_ids: self.formats.map(&:id), + } + end end diff --git a/WcaOnRails/app/models/format.rb b/WcaOnRails/app/models/format.rb index 07b72e4a05..376dbff3bf 100644 --- a/WcaOnRails/app/models/format.rb +++ b/WcaOnRails/app/models/format.rb @@ -8,4 +8,16 @@ class Format < ApplicationRecord has_many :events, through: :preferred_formats scope :recommended, -> { where("ranking = 1") } + + def serializable_hash(options = nil) + { + id: self.id, + name: self.name, + sort_by: self.sort_by, + sort_by_second: self.sort_by_second, + expected_solve_count: self.expected_solve_count, + trim_fastest_n: self.trim_fastest_n, + trim_slowest_n: self.trim_slowest_n, + } + end end From c51541fad181e881c6491eb884cd9a4b91174c1e Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman Date: Tue, 2 May 2017 18:14:15 -0700 Subject: [PATCH 03/42] Added react and built a fancy edit rounds ui that saves WCIF via ajax. Fixes #1500. --- WcaOnRails/.babelrc | 4 +- .../assets/stylesheets/application.css.scss | 1 + .../app/assets/stylesheets/edit_events.scss | 30 + WcaOnRails/app/assets/stylesheets/wca.scss | 50 ++ .../controllers/competitions_controller.rb | 2 +- .../app/javascript/edit-events/EditEvents.jsx | 209 +++++++ .../app/javascript/edit-events/index.jsx | 44 ++ .../app/javascript/edit-events/modals.jsx | 343 +++++++++++ .../app/javascript/packs/edit_events.js | 1 + WcaOnRails/app/javascript/wca/events.js.erb | 26 + WcaOnRails/app/javascript/wca/formats.js.erb | 3 + WcaOnRails/app/models/round.rb | 4 + .../views/competitions/edit_events.html.erb | 48 +- .../views/competitions/show_events.html.erb | 1 + WcaOnRails/config/locales/en.yml | 1 - WcaOnRails/config/webpack/loaders/react.js | 5 + WcaOnRails/package.json | 7 + WcaOnRails/yarn.lock | 583 +++++++++++++----- 18 files changed, 1166 insertions(+), 196 deletions(-) create mode 100644 WcaOnRails/app/assets/stylesheets/edit_events.scss create mode 100644 WcaOnRails/app/javascript/edit-events/EditEvents.jsx create mode 100644 WcaOnRails/app/javascript/edit-events/index.jsx create mode 100644 WcaOnRails/app/javascript/edit-events/modals.jsx create mode 100644 WcaOnRails/app/javascript/packs/edit_events.js create mode 100644 WcaOnRails/app/javascript/wca/events.js.erb create mode 100644 WcaOnRails/app/javascript/wca/formats.js.erb create mode 100644 WcaOnRails/config/webpack/loaders/react.js diff --git a/WcaOnRails/.babelrc b/WcaOnRails/.babelrc index ded31c0d80..3d4ec69980 100644 --- a/WcaOnRails/.babelrc +++ b/WcaOnRails/.babelrc @@ -7,7 +7,9 @@ "uglify": true }, "useBuiltIns": true - }] + }], + "react", + "stage-2" ], "plugins": [ diff --git a/WcaOnRails/app/assets/stylesheets/application.css.scss b/WcaOnRails/app/assets/stylesheets/application.css.scss index 9b82e6af39..9c6e790d3b 100644 --- a/WcaOnRails/app/assets/stylesheets/application.css.scss +++ b/WcaOnRails/app/assets/stylesheets/application.css.scss @@ -54,3 +54,4 @@ @import "competition_tabs"; @import "server_status"; @import "relations"; +@import "edit_events"; diff --git a/WcaOnRails/app/assets/stylesheets/edit_events.scss b/WcaOnRails/app/assets/stylesheets/edit_events.scss new file mode 100644 index 0000000000..6f0c2228bc --- /dev/null +++ b/WcaOnRails/app/assets/stylesheets/edit_events.scss @@ -0,0 +1,30 @@ +#events-edit-area { + .panel-heading { + padding-top: 10px; + padding-bottom: 10px; + + .panel-title { + white-space: nowrap; + overflow: hidden; + + $cubing-icon-size: 55px; + .img-thumbnail.cubing-icon { + font-size: 40px; + position: absolute; + top: -5px; + height: $cubing-icon-size; + z-index: 1; + } + + .title { + padding-left: $cubing-icon-size; + } + } + } + + .panel-body { + select { + width: 100%; + } + } +} diff --git a/WcaOnRails/app/assets/stylesheets/wca.scss b/WcaOnRails/app/assets/stylesheets/wca.scss index e1c0f4c038..ac5df4c945 100644 --- a/WcaOnRails/app/assets/stylesheets/wca.scss +++ b/WcaOnRails/app/assets/stylesheets/wca.scss @@ -365,3 +365,53 @@ a.plain { color: inherit; text-decoration: none; } + +// From http://stackoverflow.com/a/22892773 +.row.equal { + display: flex; + flex-wrap: wrap; +} + +// Modified from http://bootsnipp.com/snippets/featured/loading-button-effect-no-js +button.saving { + @keyframes ld { + 0% { transform: rotate(0deg) scale(1); } + 50% { transform: rotate(180deg) scale(1.1); } + 100% { transform: rotate(360deg) scale(1); } + } + + position: relative; + opacity: 0.8; + + &:hover, + &:active, + &:focus { + cursor: default; + box-shadow: none; + } + + &::before { + content: ''; + + display: inline-block; + + position: absolute; + background: transparent; + border: 1px solid currentColor; + border-top-color: transparent; + border-bottom-color: transparent; + border-radius: 50%; + + box-sizing: border-box; + + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -12px; + + width: 24px; + height: 24px; + + animation: ld 1s ease-in-out infinite; + } +} diff --git a/WcaOnRails/app/controllers/competitions_controller.rb b/WcaOnRails/app/controllers/competitions_controller.rb index 053ecb30f6..0ae5253990 100644 --- a/WcaOnRails/app/controllers/competitions_controller.rb +++ b/WcaOnRails/app/controllers/competitions_controller.rb @@ -322,7 +322,7 @@ def show_events end def edit_events - @competition = competition_from_params + @competition = competition_from_params(includes: [competition_events: { rounds: [:competition_event] }]) end def update_events diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx new file mode 100644 index 0000000000..9f529741bb --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -0,0 +1,209 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import classNames from 'classnames' + +import events from 'wca/events.js.erb' +import { rootRender, promiseSaveWcif } from 'edit-events' +import { EditTimeLimitButton, EditCutoffButton, EditAdvancementConditionButton } from 'edit-events/modals' + +export default class EditEvents extends React.Component { + constructor(props) { + super(props); + this.save = this.save.bind(this); + this.onUnload = this.onUnload.bind(this); + } + + save(e) { + let {competitionId, wcifEvents} = this.props; + let wcif = { + id: competitionId, + events: wcifEvents, + }; + + this.setState({ saving: true }); + promiseSaveWcif(wcif).then(response => { + if(!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + return response; + }).then(() => { + this.setState({ savedWcifEvents: clone(this.props.wcifEvents), saving: false }); + }).catch(() => { + this.setState({ saving: false }); + alert("Something went wrong while saving."); + }); + } + + unsavedChanges() { + return !deepEqual(this.state.savedWcifEvents, this.props.wcifEvents); + } + + onUnload(e) { + if(this.unsavedChanges()) { + var confirmationMessage = "\o/"; + e.returnValue = confirmationMessage; + return confirmationMessage; + } + } + + componentWillMount() { + this.setState({ savedWcifEvents: clone(this.props.wcifEvents) }); + } + + componentDidMount() { + window.addEventListener("beforeunload", this.onUnload); + } + + componentWillUnmount() { + window.removeEventListener("beforeunload", this.onUnload); + } + + render() { + let { competitionId, wcifEvents } = this.props; + return ( +
+
+ {wcifEvents.map(wcifEvent => { + return ( +
+ +
+ ); + })} +
+ +
+ ); + } +} + +function RoundsTable({ wcifEvents, wcifEvent }) { + let event = events.byId[wcifEvent.id]; + return ( +
+ + + + + + + + + + + + {wcifEvent.rounds.map((wcifRound, index) => { + let roundNumber = index + 1; + let isLastRound = roundNumber == wcifEvent.rounds.length; + + let roundFormatChanged = e => { + let newFormat = e.target.value; + wcifRound.format = newFormat; + rootRender(); + }; + + return ( + + + + + + + + + + + ); + })} + +
#FormatTime LimitCutoffTo Advance
{roundNumber} + + + + + + + {!isLastRound && } +
+
+ ); +} + +const EventPanel = ({ wcifEvents, wcifEvent }) => { + let event = events.byId[wcifEvent.id]; + let roundCountChanged = e => { + let newRoundCount = parseInt(e.target.value); + if(wcifEvent.rounds.length > newRoundCount) { + // We have too many rounds, remove the extras. + wcifEvent.rounds = wcifEvent.rounds.slice(0, newRoundCount); + + // Final rounds must not have an advance to next round requirement. + if(wcifEvent.rounds.length >= 1) { + let lastRound = wcifEvent.rounds[wcifEvent.rounds.length - 1]; + lastRound.advancementCondition = null; + } + } else { + // We do not have enough rounds, create the missing ones. + while(wcifEvent.rounds.length < newRoundCount) { + addRoundToEvent(wcifEvent); + } + } + rootRender(); + }; + + return ( +
+
+

+ + {event.name} + {" "} + +

+
+ + {wcifEvent.rounds.length > 0 && ( +
+ +
+ )} +
+ ); +}; + +function addRoundToEvent(wcifEvent) { + const DEFAULT_TIME_LIMIT = { centiseconds: 10*60*100, cumulativeRoundIds: [] }; + let event = events.byId[wcifEvent.id]; + let nextRoundNumber = wcifEvent.rounds.length + 1; + wcifEvent.rounds.push({ + id: `${wcifEvent.id}-${nextRoundNumber}`, + format: event.recommended_format().id, + timeLimit: DEFAULT_TIME_LIMIT, + cutoff: null, + advancementCondition: null, + results: [], + groups: [], + }); +} + +function clone(obj) { + return JSON.parse(JSON.stringify(obj)); +} + +function deepEqual(obj1, obj2) { + return JSON.stringify(obj1) == JSON.stringify(obj2); +} diff --git a/WcaOnRails/app/javascript/edit-events/index.jsx b/WcaOnRails/app/javascript/edit-events/index.jsx new file mode 100644 index 0000000000..fabda22f5f --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/index.jsx @@ -0,0 +1,44 @@ +import React from 'react' +import ReactDOM from 'react-dom' + +import EditEvents from './EditEvents' +import events from 'wca/events.js.erb' + +function getAuthenticityToken() { + return document.querySelector('meta[name=csrf-token]').content; +} + +export function promiseSaveWcif(wcif) { + let url = `/competitions/${wcif.id}/wcif/events`; + let fetchOptions = { + headers: new Headers({ + "Content-Type": "application/json", + "X-CSRF-Token": getAuthenticityToken(), + }), + credentials: 'include', + method: "PATCH", + body: JSON.stringify(wcif.events), + }; + + return fetch(url, fetchOptions); +} + +let state = {}; +export function rootRender() { + ReactDOM.render( + , + document.getElementById('events-edit-area'), + ) +} + +function normalizeWcifEvents(wcifEvents) { + return events.official.map(event => { + return wcifEvents.find(wcifEvent => wcifEvent.id == event.id) || { id: event.id, rounds: [] }; + }); +} + +wca.initializeEventsForm = (competitionId, wcifEvents) => { + state.competitionId = competitionId; + state.wcifEvents = normalizeWcifEvents(wcifEvents); + rootRender(); +} diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx new file mode 100644 index 0000000000..bdfbaa2a64 --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -0,0 +1,343 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import Modal from 'react-bootstrap/lib/Modal' +import Button from 'react-bootstrap/lib/Button' +import Checkbox from 'react-bootstrap/lib/Checkbox' + +import events from 'wca/events.js.erb' +import formats from 'wca/formats.js.erb' +import { rootRender } from 'edit-events' + +class ButtonActivatedModal extends React.Component { + constructor() { + super(); + this.state = { showModal: false }; + } + + open = () => { + this.setState({ showModal: true }); + } + + close = () => { + this.props.reset(); + this.setState({ showModal: false }); + } + + render() { + return ( + + + + + + + + ); + } +} + +class EditRoundAttribute extends React.Component { + componentWillMount() { + this.reset(); + } + + getWcifRound() { + let { wcifEvent, roundNumber } = this.props; + return wcifEvent.rounds[roundNumber - 1]; + } + + getSavedValue() { + return this.getWcifRound()[this.props.attribute]; + } + + onChange = (value) => { + this.setState({ value: value }); + } + + onSave = () => { + this.getWcifRound()[this.props.attribute] = this.state.value; + this._modal.close(); + rootRender(); + } + + reset = () => { + this.setState({ value: this.getSavedValue() }); + } + + render() { + let { wcifEvents, wcifEvent, roundNumber } = this.props; + let wcifRound = this.getWcifRound(); + let Show = RoundAttributeComponents[this.props.attribute].Show; + let Input = RoundAttributeComponents[this.props.attribute].Input; + let Title = RoundAttributeComponents[this.props.attribute].Title; + + return ( + } + onSave={this.onSave} + reset={this.reset} + ref={c => this._modal = c} + > + + </Modal.Title> + </Modal.Header> + <Modal.Body> + <Input value={this.state.value} wcifEvents={wcifEvents} wcifEvent={wcifEvent} roundNumber={roundNumber} onChange={this.onChange} autoFocus /> + </Modal.Body> + </ButtonActivatedModal> + ); + } +} + +let RoundAttributeComponents = { + timeLimit: { + Title({ wcifEvent, roundNumber }) { + let event = events.byId[wcifEvent.id]; + return <span>Time limit for {event.name} round {roundNumber}</span>; + }, + Show({ value: timeLimit }) { + let timeStr = `${(timeLimit.centiseconds / 100 / 60).toFixed(2)} minutes`; + let str; + switch(timeLimit.cumulativeRoundIds.length) { + case 0: + str = timeStr; + break; + case 1: + str = timeStr + " cumulative"; + break; + default: + str = timeStr + ` total for ${timeLimit.cumulativeRoundIds.join(", ")}`; + break; + } + return <span>{str}</span>; + }, + Input: function({ value: timeLimit, autoFocus, wcifEvents, wcifEvent, roundNumber, onChange }) { + let event = events.byId[wcifEvent.id]; + let wcifRound = wcifEvent.rounds[roundNumber - 1]; + let format = formats.byId[wcifRound.format]; + + let otherWcifRounds = []; + wcifEvents.forEach(wcifEvent => { + otherWcifRounds = otherWcifRounds.concat(wcifEvent.rounds.filter(r => r != wcifRound)); + }); + + let centisInput, cumulativeInput; + let roundCheckboxes = []; + let onChangeAggregator = () => { + let cumulativeRoundIds; + switch(cumulativeInput.value) { + case "per-solve": + cumulativeRoundIds = []; + break; + case "cumulative": + cumulativeRoundIds = [wcifRound.id]; + cumulativeRoundIds = cumulativeRoundIds.concat(roundCheckboxes.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value)); + break; + default: + throw new Error(`Unrecognized value ${cumulativeInput.value}`); + break; + } + + let newTimeLimit = { + centiseconds: parseInt(centisInput.value), + cumulativeRoundIds, + }; + onChange(newTimeLimit); + }; + return ( + <span> + centis + <input type="number" + autoFocus={autoFocus} + ref={c => centisInput = c} + value={timeLimit.centiseconds} + onChange={onChangeAggregator} /> + + <select type="checkbox" + value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} + onChange={onChangeAggregator} + ref={c => cumulativeInput = c} + > + <option value="per-solve">per solve</option> + <option value="cumulative">cumulative</option> + </select> + + {timeLimit.cumulativeRoundIds.length >= 1 && ( + <span> + {otherWcifRounds.map(wcifRound => { + let roundId = wcifRound.id; + return ( + <label key={roundId}> + <input type="checkbox" + value={roundId} + checked={timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0} + ref={c => roundCheckboxes.push(c) } + onChange={onChangeAggregator} /> + {roundId} + </label> + ); + })} + </span> + )} + </span> + ); + }, + }, + cutoff: { + Title({ wcifEvent, roundNumber }) { + let event = events.byId[wcifEvent.id]; + return <span>Cutoff for {event.name} round {roundNumber}</span>; + }, + Show({ value: cutoff }) { + let str; + if(cutoff) { + str = `better than or equal to ${cutoff.attemptResult} in ${cutoff.numberOfAttempts}`; + } else { + str = "-"; + } + return <span>{str}</span>; + }, + Input({ value: cutoff, onChange, autoFocus }) { + let numberOfAttemptsInput, attemptResultInput; + let onChangeAggregator = () => { + let numberOfAttempts = parseInt(numberOfAttemptsInput.value); + let newCutoff; + if(numberOfAttempts > 0) { + newCutoff = { + numberOfAttempts, + attemptResult: attemptResultInput ? parseInt(attemptResultInput.value) : 0, + }; + } else { + newCutoff = null; + } + onChange(newCutoff); + }; + + return ( + <span> + <select value={cutoff ? cutoff.numberOfAttempts : 0} + autoFocus={autoFocus} + onChange={onChangeAggregator} + ref={c => numberOfAttemptsInput = c} + > + <option value={0}>No cutoff</option> + <option disabled="disabled">────────</option> + <option value={1}>1 attempt</option> + <option value={2}>2 attempts</option> + <option value={3}>3 attempts</option> + </select> + {cutoff && ( + <span> + {" "}to get better than or equal to{" "} + <input type="number" + value={cutoff.attemptResult} + onChange={onChangeAggregator} + ref={c => attemptResultInput = c} + /> + </span> + )} + </span> + ); + }, + }, + advancementCondition: { + Title({ wcifEvent, roundNumber }) { + let event = events.byId[wcifEvent.id]; + return <span>Requirement to advance from {event.name} round {roundNumber} to round {roundNumber + 1}</span>; + }, + Show({ value: advancementCondition }) { + function advanceReqToStr(advancementCondition) { + return advancementCondition ? `${advancementCondition.type} ${advancementCondition.level}` : "-"; + } + let str = advanceReqToStr(advancementCondition); + return <span>{str}</span>; + }, + Input({ value: advancementCondition, onChange, autoFocus }) { + let typeInput, rankingInput, percentileInput, attemptResultInput; + let onChangeAggregator = () => { + let type = typeInput.value; + let newAdvancementCondition; + switch(typeInput.value) { + case "ranking": + newAdvancementCondition = { + type: "ranking", + level: rankingInput ? parseInt(rankingInput.value): 0, + }; + break; + case "percentile": + newAdvancementCondition = { + type: "percentile", + level: percentileInput ? parseInt(percentileInput.value) : 0, + }; + break; + case "attemptValue": + newAdvancementCondition = { + type: "attemptValue", + level: attemptResultInput ? parseInt(attemptValue.value) : 0, + }; + break; + default: + newAdvancementCondition = null; + break; + } + onChange(newAdvancementCondition); + }; + + return ( + <span> + <select value={advancementCondition ? advancementCondition.type : ""} + autoFocus={autoFocus} + onChange={onChangeAggregator} + ref={c => typeInput = c} + > + <option value="">TBA</option> + <option disabled="disabled">────────</option> + <option value="ranking">Ranking</option> + <option value="percentile">Percentile</option> + <option value="attemptValue">Attempt value</option> + </select> + + {advancementCondition && advancementCondition.type == "ranking" && ( + <span> + <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} /> + ranking? + </span> + )} + + {advancementCondition && advancementCondition.type == "percentile" && ( + <span> + <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentileInput = c} /> + percentile? + </span> + )} + + {advancementCondition && advancementCondition.type == "attemptValue" && ( + <span> + <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} /> + my shirt? + </span> + )} + </span> + ); + }, + }, +}; + +export function EditTimeLimitButton(props) { + return <EditRoundAttribute {...props} attribute="timeLimit" />; +}; + +export function EditCutoffButton(props) { + return <EditRoundAttribute {...props} attribute="cutoff" />; +}; + +export function EditAdvancementConditionButton(props) { + return <EditRoundAttribute {...props} attribute="advancementCondition" />; +}; diff --git a/WcaOnRails/app/javascript/packs/edit_events.js b/WcaOnRails/app/javascript/packs/edit_events.js new file mode 100644 index 0000000000..a88c81a284 --- /dev/null +++ b/WcaOnRails/app/javascript/packs/edit_events.js @@ -0,0 +1 @@ +import 'edit-events'; diff --git a/WcaOnRails/app/javascript/wca/events.js.erb b/WcaOnRails/app/javascript/wca/events.js.erb new file mode 100644 index 0000000000..6f14cf1ada --- /dev/null +++ b/WcaOnRails/app/javascript/wca/events.js.erb @@ -0,0 +1,26 @@ +import formats from 'wca/formats.js.erb'; + +export default { + official: <%= Event.official.to_json.html_safe %>.map(extend), + byId: mapObjectValues(<%= Event.all.index_by(&:id).to_json.html_safe %>, extend), +}; + +function mapObjectValues(obj, func) { + let newObj = {}; + Object.keys(obj).forEach(key => { + newObj[key] = func(obj[key]); + }); + return newObj; +} + +function extend(rawEvent) { + return { + ...rawEvent, + formats() { + return rawEvent.format_ids.map(format_id => formats.byId[format_id]); + }, + recommended_format() { + return this.formats()[0]; + }, + } +} diff --git a/WcaOnRails/app/javascript/wca/formats.js.erb b/WcaOnRails/app/javascript/wca/formats.js.erb new file mode 100644 index 0000000000..8226002aa2 --- /dev/null +++ b/WcaOnRails/app/javascript/wca/formats.js.erb @@ -0,0 +1,3 @@ +export default { + byId: <%= Format.all.index_by(&:id).to_json.html_safe %>, +}; diff --git a/WcaOnRails/app/models/round.rb b/WcaOnRails/app/models/round.rb index 7759b21a23..5169611fd9 100644 --- a/WcaOnRails/app/models/round.rb +++ b/WcaOnRails/app/models/round.rb @@ -33,6 +33,10 @@ class Round < ApplicationRecord end end + def event + Event.c_find(competition_event.event_id) + end + def final_round? competition_event.rounds.last == self end diff --git a/WcaOnRails/app/views/competitions/edit_events.html.erb b/WcaOnRails/app/views/competitions/edit_events.html.erb index ae6c111c43..a9696e11ed 100644 --- a/WcaOnRails/app/views/competitions/edit_events.html.erb +++ b/WcaOnRails/app/views/competitions/edit_events.html.erb @@ -1,42 +1,14 @@ <% provide(:title, @competition.name) %> +<%= javascript_pack_tag 'edit_events' %> <%= render layout: 'nav' do %> - <% disable_form = @competition.isConfirmed? && !current_user.can_admin_results? %> - <%= horizontal_simple_form_for @competition, :url => update_events_path, html: { class: 'are-you-sure no-submit-on-enter', id: 'edit-competition-events' } do |f| %> - <% event_groups = [ - [ - t('competitions.competition_form.events'), - true, - @competition.saved_and_unsaved_events.select(&:official?), - Event.official, - ], - [ - t('competitions.competition_form.unoff_events'), - false, # Only show deprecated events if this competition has any. - @competition.saved_and_unsaved_events.select(&:deprecated?), - Event.deprecated, - ] - ] %> - <% event_groups.each do |label, always_show, selected_events, allowed_events| %> - <% if always_show || !selected_events.empty? %> - <%= render "shared/associated_events_picker", - form_builder: f, - disabled: disable_form, - events_association_name: :competition_events, allowed_events: allowed_events, - selected_events: selected_events %> - <% end %> - <% end %> - <div class="form-group"> - <div class="col-sm-offset-2 col-sm-10"> - <%= f.button :submit, - t('competitions.competition_form.submit_modify_value'), - class: disable_form ? "btn-primary disabled" : "btn-primary", - disabled: disable_form - %> - </div> - </div> - <% end %> - <% if disable_form %> - <p>You can't manage a competition's events after it has been confirmed.</p> - <% end %> + <div id="events-edit-area"></div> + <script> + $(function() { + wca.initializeEventsForm( + <%= @competition.id.to_json.html_safe %>, + <%= @competition.competition_events.map(&:to_wcif).to_json.html_safe %> + ); + }); + </script> <% end %> diff --git a/WcaOnRails/app/views/competitions/show_events.html.erb b/WcaOnRails/app/views/competitions/show_events.html.erb index e42b862e86..9059d4b1b1 100644 --- a/WcaOnRails/app/views/competitions/show_events.html.erb +++ b/WcaOnRails/app/views/competitions/show_events.html.erb @@ -24,6 +24,7 @@ <tbody> <% @competition.competition_events.each do |competition_event| %> + <% next if competition_event.rounds.length == 0 %> <tr> <td> <%= competition_event.event.name %> diff --git a/WcaOnRails/config/locales/en.yml b/WcaOnRails/config/locales/en.yml index b096a0a4d9..18d7595d18 100644 --- a/WcaOnRails/config/locales/en.yml +++ b/WcaOnRails/config/locales/en.yml @@ -876,7 +876,6 @@ en: confirmed_not_visible_html: "You've confirmed this competition, but it is not yet visible to the public. Wait for the %{board} to make it visible." is_visible: "This competition is publicly visible, any changes you make will show up to the public!" awaiting_confirmation_html: "Fill in all the fields and click Confirm when you're ready for the %{board} to approve this competition." - submit_modify_value: "Modify Events" submit_create_value: "Create Competition" submit_update_value: "Update Competition" submit_confirm_value: "Confirm" diff --git a/WcaOnRails/config/webpack/loaders/react.js b/WcaOnRails/config/webpack/loaders/react.js new file mode 100644 index 0000000000..cfd6417745 --- /dev/null +++ b/WcaOnRails/config/webpack/loaders/react.js @@ -0,0 +1,5 @@ +module.exports = { + test: /\.(js|jsx)?(\.erb)?$/, + exclude: /node_modules/, + loader: 'babel-loader' +} diff --git a/WcaOnRails/package.json b/WcaOnRails/package.json index 99ddc6f09c..c3f21a8f94 100644 --- a/WcaOnRails/package.json +++ b/WcaOnRails/package.json @@ -9,6 +9,9 @@ "babel-polyfill": "^6.23.0", "babel-preset-env": "^1.6.0", "blueimp-load-image": "^2.14.0", + "babel-preset-react": "^6.24.1", + "babel-preset-stage-2": "^6.24.1", + "classnames": "^2.2.5", "coffee-loader": "^0.8.0", "coffee-script": "^1.12.7", "compression-webpack-plugin": "^1.0.0", @@ -22,8 +25,12 @@ "postcss-loader": "^2.0.6", "postcss-smart-import": "^0.7.5", "precss": "^2.0.0", + "prop-types": "^15.5.8", "rails-erb-loader": "^5.0.2", "resolve-url-loader": "^2.1.0", + "react": "^15.5.4", + "react-bootstrap": "^0.31.0", + "react-dom": "^15.5.4", "sass-loader": "^6.0.6", "simplemde": "^1.11.2", "style-loader": "^0.18.2", diff --git a/WcaOnRails/yarn.lock b/WcaOnRails/yarn.lock index 28926ad69a..d92f607386 100644 --- a/WcaOnRails/yarn.lock +++ b/WcaOnRails/yarn.lock @@ -55,8 +55,8 @@ acorn@^4.0.3: resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" acorn@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.1.tgz#53fe161111f912ab999ee887a90a0bc52822fd75" + version "5.1.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.2.tgz#911cb53e036807cf0fa778dc5d370fbd864246d7" adjust-sourcemap-loader@^1.1.0: version "1.1.0" @@ -199,6 +199,10 @@ array-unique@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + asn1.js@^4.0.0: version "4.9.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.1.tgz#48ba240b45a9280e94748990ba597d216617fd40" @@ -268,18 +272,7 @@ autoprefixer@^6.3.1: postcss "^5.2.16" postcss-value-parser "^3.2.3" -autoprefixer@^7.1.1: - version "7.1.3" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-7.1.3.tgz#0e8d337976d6f13644db9f8813b4c42f3d1ccc34" - dependencies: - browserslist "^2.4.0" - caniuse-lite "^1.0.30000718" - normalize-range "^0.1.2" - num2fraction "^1.2.2" - postcss "^6.0.10" - postcss-value-parser "^3.2.3" - -autoprefixer@^7.1.4: +autoprefixer@^7.1.1, autoprefixer@^7.1.4: version "7.1.4" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-7.1.4.tgz#960847dbaa4016bc8e8e52ec891cbf8f1257a748" dependencies: @@ -343,6 +336,14 @@ babel-generator@^6.26.0: source-map "^0.5.6" trim-right "^1.0.1" +babel-helper-bindify-decorators@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-bindify-decorators/-/babel-helper-bindify-decorators-6.24.1.tgz#14c19e5f142d7b47f19a52431e52b1ccbc40a330" + dependencies: + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + babel-helper-builder-binary-assignment-operator-visitor@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz#cce4517ada356f4220bcae8a02c2b346f9a56664" @@ -351,6 +352,14 @@ babel-helper-builder-binary-assignment-operator-visitor@^6.24.1: babel-runtime "^6.22.0" babel-types "^6.24.1" +babel-helper-builder-react-jsx@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.26.0.tgz#39ff8313b75c8b65dceff1f31d383e0ff2a408a0" + dependencies: + babel-runtime "^6.26.0" + babel-types "^6.26.0" + esutils "^2.0.2" + babel-helper-call-delegate@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz#ece6aacddc76e41c3461f88bfc575bd0daa2df8d" @@ -377,6 +386,15 @@ babel-helper-explode-assignable-expression@^6.24.1: babel-traverse "^6.24.1" babel-types "^6.24.1" +babel-helper-explode-class@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-explode-class/-/babel-helper-explode-class-6.24.1.tgz#7dc2a3910dee007056e1e31d640ced3d54eaa9eb" + dependencies: + babel-helper-bindify-decorators "^6.24.1" + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + babel-helper-function-name@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz#d3475b8c03ed98242a25b48351ab18399d3580a9" @@ -468,10 +486,18 @@ babel-plugin-syntax-async-functions@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" +babel-plugin-syntax-async-generators@^6.5.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-generators/-/babel-plugin-syntax-async-generators-6.13.0.tgz#6bc963ebb16eccbae6b92b596eb7f35c342a8b9a" + babel-plugin-syntax-class-properties@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz#d7eb23b79a317f8543962c505b827c7d6cac27de" +babel-plugin-syntax-decorators@^6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.13.0.tgz#312563b4dbde3cc806cee3e416cceeaddd11ac0b" + babel-plugin-syntax-dynamic-import@^6.18.0: version "6.18.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz#8d6a26229c83745a9982a441051572caa179b1da" @@ -480,6 +506,14 @@ babel-plugin-syntax-exponentiation-operator@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de" +babel-plugin-syntax-flow@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz#4c3ab20a2af26aa20cd25995c398c4eb70310c8d" + +babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -488,7 +522,15 @@ babel-plugin-syntax-trailing-function-commas@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3" -babel-plugin-transform-async-to-generator@^6.22.0: +babel-plugin-transform-async-generator-functions@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-generator-functions/-/babel-plugin-transform-async-generator-functions-6.24.1.tgz#f058900145fd3e9907a6ddf28da59f215258a5db" + dependencies: + babel-helper-remap-async-to-generator "^6.24.1" + babel-plugin-syntax-async-generators "^6.5.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-async-to-generator@^6.22.0, babel-plugin-transform-async-to-generator@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761" dependencies: @@ -505,6 +547,16 @@ babel-plugin-transform-class-properties@^6.24.1: babel-runtime "^6.22.0" babel-template "^6.24.1" +babel-plugin-transform-decorators@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-decorators/-/babel-plugin-transform-decorators-6.24.1.tgz#788013d8f8c6b5222bdf7b344390dfd77569e24d" + dependencies: + babel-helper-explode-class "^6.24.1" + babel-plugin-syntax-decorators "^6.13.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-types "^6.24.1" + babel-plugin-transform-es2015-arrow-functions@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz#452692cb711d5f79dc7f85e440ce41b9f244d221" @@ -673,7 +725,7 @@ babel-plugin-transform-es2015-unicode-regex@^6.22.0: babel-runtime "^6.22.0" regexpu-core "^2.0.0" -babel-plugin-transform-exponentiation-operator@^6.22.0: +babel-plugin-transform-exponentiation-operator@^6.22.0, babel-plugin-transform-exponentiation-operator@^6.24.1: version "6.24.1" resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e" dependencies: @@ -681,13 +733,48 @@ babel-plugin-transform-exponentiation-operator@^6.22.0: babel-plugin-syntax-exponentiation-operator "^6.8.0" babel-runtime "^6.22.0" -babel-plugin-transform-object-rest-spread@^6.26.0: +babel-plugin-transform-flow-strip-types@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz#84cb672935d43714fdc32bce84568d87441cf7cf" + dependencies: + babel-plugin-syntax-flow "^6.18.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-object-rest-spread@^6.22.0, babel-plugin-transform-object-rest-spread@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06" dependencies: babel-plugin-syntax-object-rest-spread "^6.8.0" babel-runtime "^6.26.0" +babel-plugin-transform-react-display-name@^6.23.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz#67e2bf1f1e9c93ab08db96792e05392bf2cc28d1" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx-self@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz#df6d80a9da2612a121e6ddd7558bcbecf06e636e" + dependencies: + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx-source@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz#66ac12153f5cd2d17b3c19268f4bf0197f44ecd6" + dependencies: + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz#840a028e7df460dfc3a2d29f0c0d91f6376e66a3" + dependencies: + babel-helper-builder-react-jsx "^6.24.1" + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + babel-plugin-transform-regenerator@^6.22.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f" @@ -744,6 +831,42 @@ babel-preset-env@^1.6.0: invariant "^2.2.2" semver "^5.3.0" +babel-preset-flow@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz#e71218887085ae9a24b5be4169affb599816c49d" + dependencies: + babel-plugin-transform-flow-strip-types "^6.22.0" + +babel-preset-react@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-react/-/babel-preset-react-6.24.1.tgz#ba69dfaea45fc3ec639b6a4ecea6e17702c91380" + dependencies: + babel-plugin-syntax-jsx "^6.3.13" + babel-plugin-transform-react-display-name "^6.23.0" + babel-plugin-transform-react-jsx "^6.24.1" + babel-plugin-transform-react-jsx-self "^6.22.0" + babel-plugin-transform-react-jsx-source "^6.22.0" + babel-preset-flow "^6.23.0" + +babel-preset-stage-2@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-stage-2/-/babel-preset-stage-2-6.24.1.tgz#d9e2960fb3d71187f0e64eec62bc07767219bdc1" + dependencies: + babel-plugin-syntax-dynamic-import "^6.18.0" + babel-plugin-transform-class-properties "^6.24.1" + babel-plugin-transform-decorators "^6.24.1" + babel-preset-stage-3 "^6.24.1" + +babel-preset-stage-3@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-stage-3/-/babel-preset-stage-3-6.24.1.tgz#836ada0a9e7a7fa37cb138fb9326f87934a48395" + dependencies: + babel-plugin-syntax-trailing-function-commas "^6.22.0" + babel-plugin-transform-async-generator-functions "^6.24.1" + babel-plugin-transform-async-to-generator "^6.24.1" + babel-plugin-transform-exponentiation-operator "^6.24.1" + babel-plugin-transform-object-rest-spread "^6.22.0" + babel-register@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071" @@ -756,7 +879,7 @@ babel-register@^6.26.0: mkdirp "^0.5.1" source-map-support "^0.4.15" -babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0: +babel-runtime@^6.11.6, babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" dependencies: @@ -827,8 +950,8 @@ bcrypt-pbkdf@^1.0.0: tweetnacl "^0.14.3" big.js@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.1.3.tgz#4cada2193652eb3ca9ec8e55c9015669c9806978" + version "3.2.0" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" binary-extensions@^1.0.0: version "1.10.0" @@ -885,14 +1008,15 @@ brorand@^1.0.1: resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" browserify-aes@^1.0.0, browserify-aes@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.6.tgz#5e7725dbdef1fd5930d4ebab48567ce451c48a0a" + version "1.0.8" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.8.tgz#c8fa3b1b7585bb7ba77c5560b60996ddec6d5309" dependencies: - buffer-xor "^1.0.2" + buffer-xor "^1.0.3" cipher-base "^1.0.0" create-hash "^1.1.0" - evp_bytestokey "^1.0.0" + evp_bytestokey "^1.0.3" inherits "^2.0.1" + safe-buffer "^5.0.1" browserify-cipher@^1.0.0: version "1.0.0" @@ -953,7 +1077,7 @@ buffer-indexof@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" -buffer-xor@^1.0.2: +buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" @@ -1023,20 +1147,12 @@ caniuse-api@^2.0.0: lodash.uniq "^4.5.0" caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639: - version "1.0.30000721" - resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000721.tgz#cdc52efe8f82dd13916615b78e86f704ece61802" - -caniuse-lite@^1.0.0: - version "1.0.30000722" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000722.tgz#8cbfe07440478e3a16ab0d3b182feef1901eab55" + version "1.0.30000732" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000732.tgz#ddb3c885e88caf779c7080ee09653fb85d1bd24b" -caniuse-lite@^1.0.30000718: - version "1.0.30000721" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000721.tgz#931a21a7bd85016300328d21f126d84b73437d35" - -caniuse-lite@^1.0.30000726: - version "1.0.30000726" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000726.tgz#966a753fa107a09d4131cf8b3d616723a06ccf7e" +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000718, caniuse-lite@^1.0.30000726: + version "1.0.30000732" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000732.tgz#7cf9ca565f4d31a4b3dfa6e26b72ec22e9027da1" caseless@~0.12.0: version "0.12.0" @@ -1095,6 +1211,10 @@ clap@^1.0.9: dependencies: chalk "^1.1.3" +classnames@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" + cliui@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" @@ -1278,8 +1398,8 @@ content-disposition@0.5.2: resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" content-type@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.2.tgz#b7d113aee7a8dd27bd21133c4dc2529df1721eed" + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" convert-source-map@^0.3.3: version "0.3.5" @@ -1297,6 +1417,10 @@ cookie@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + core-js@^2.4.0, core-js@^2.5.0: version "2.5.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b" @@ -1344,6 +1468,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: safe-buffer "^5.0.1" sha.js "^2.4.8" +create-react-class@^15.6.0: + version "15.6.0" + resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.0.tgz#ab448497c26566e1e29413e883207d57cfe7bed4" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + object-assign "^4.1.1" + cross-spawn@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" @@ -1521,7 +1653,7 @@ decimal.js@7.2.3: version "7.2.3" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-7.2.3.tgz#6434c3b8a8c375780062fc633d0d2bbdb264cc78" -deep-equal@^1.0.1: +deep-equal@^1.0.1, deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -1529,9 +1661,9 @@ deep-extend@~0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f" -default-gateway@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-2.0.2.tgz#e365db05c50a4643cc1990c6178228c540a0b910" +default-gateway@^2.2.2: + version "2.5.0" + resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-2.5.0.tgz#78e24dbd2e1df7490c2b8050515b8e816bfa7da5" dependencies: execa "^0.7.0" ip-regex "^2.1.0" @@ -1543,7 +1675,7 @@ define-properties@^1.1.2: foreach "^2.0.5" object-keys "^1.0.8" -defined@^1.0.0: +defined@^1.0.0, defined@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" @@ -1616,6 +1748,10 @@ dns-txt@^2.0.2: dependencies: buffer-indexof "^1.0.0" +dom-helpers@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a" + domain-browser@^1.1.1: version "1.1.7" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" @@ -1631,8 +1767,8 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" electron-to-chromium@^1.2.7, electron-to-chromium@^1.3.18: - version "1.3.20" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.20.tgz#2eedd5ccbae7ddc557f68ad1fce9c172e915e4e5" + version "1.3.21" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.21.tgz#a967ebdcfe8ed0083fc244d1894022a8e8113ea2" elliptic@^6.0.0: version "6.4.0" @@ -1654,6 +1790,12 @@ encodeurl@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + dependencies: + iconv-lite "~0.4.13" + enhanced-resolve@^3.4.0: version "3.4.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e" @@ -1675,7 +1817,7 @@ error-ex@^1.2.0: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.7.0: +es-abstract@^1.5.0, es-abstract@^1.7.0: version "1.8.2" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.8.2.tgz#25103263dc4decbda60e0c737ca32313518027ee" dependencies: @@ -1786,8 +1928,8 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" etag@~1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051" + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" event-emitter@~0.3.5: version "0.3.5" @@ -1810,9 +1952,9 @@ eventsource@0.1.6: dependencies: original ">=0.0.5" -evp_bytestokey@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.2.tgz#f66bb88ecd57f71a766821e20283ea38c68bf80a" +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" dependencies: md5.js "^1.3.4" safe-buffer "^5.1.1" @@ -1917,6 +2059,18 @@ faye-websocket@~0.11.0: dependencies: websocket-driver ">=0.5.1" +fbjs@^0.8.9: + version "0.8.15" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.15.tgz#4f0695fdfcc16c37c0b07facec8cb4c4091685b9" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.9" + file-loader@^0.11.2: version "0.11.2" resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-0.11.2.tgz#4ff1df28af38719a6098093b88c82c71d1794a34" @@ -1938,14 +2092,14 @@ fill-range@^2.1.0: repeat-string "^1.5.2" finalhandler@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.4.tgz#18574f2e7c4b98b8ae3b230c21f201f31bdb3fb7" + version "1.0.5" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.5.tgz#a701303d257a1bc82fea547a33e5ae89531723df" dependencies: debug "2.6.8" encodeurl "~1.0.1" escape-html "~1.0.3" on-finished "~2.3.0" - parseurl "~1.3.1" + parseurl "~1.3.2" statuses "~1.3.1" unpipe "~1.0.0" @@ -1974,6 +2128,12 @@ flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" +for-each@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4" + dependencies: + is-function "~1.0.0" + for-in@^0.1.3: version "0.1.8" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" @@ -2011,8 +2171,8 @@ form-data@~2.1.1: mime-types "^2.1.12" forwarded@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" + version "0.1.2" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" fraction.js@4.0.2: version "4.0.2" @@ -2060,7 +2220,7 @@ fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2: mkdirp ">=0.5 0" rimraf "2" -function-bind@^1.0.2, function-bind@^1.1.1: +function-bind@^1.0.2, function-bind@^1.1.1, function-bind@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -2114,7 +2274,7 @@ glob-parent@^2.0.0: dependencies: is-glob "^2.0.0" -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1: +glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1, glob@~7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" dependencies: @@ -2190,7 +2350,7 @@ has-unicode@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" -has@^1.0.1: +has@^1.0.1, has@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28" dependencies: @@ -2278,6 +2438,10 @@ http-errors@~1.6.1, http-errors@~1.6.2: setprototypeof "1.0.3" statuses ">= 1.3.1 < 2" +http-parser-js@>=0.4.0: + version "0.4.6" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.6.tgz#195273f58704c452d671076be201329dd341dc55" + http-proxy-middleware@~0.17.4: version "0.17.4" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833" @@ -2306,6 +2470,10 @@ https-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" +iconv-lite@~0.4.13: + version "0.4.19" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" + icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -2358,17 +2526,17 @@ ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" internal-ip@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-2.0.2.tgz#bed2b35491e8b42aee087de7614e870908ee80f2" + version "2.0.3" + resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-2.0.3.tgz#ed3cf9b671ac7ff23037bfacad42eb439cd9546c" dependencies: - default-gateway "^2.0.2" - ipaddr.js "^1.5.1" + default-gateway "^2.2.2" + ipaddr.js "^1.5.2" interpret@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.3.tgz#cbc35c62eeee73f19ab7b10a801511401afc0f90" + version "1.0.4" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0" -invariant@^2.2.2: +invariant@^2.1.0, invariant@^2.2.1, invariant@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" dependencies: @@ -2390,7 +2558,7 @@ ipaddr.js@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.4.0.tgz#296aca878a821816e5b85d0a285a99bcff4582f0" -ipaddr.js@^1.5.1: +ipaddr.js@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.5.2.tgz#d4b505bde9946987ccf0fc58d9010ff9607e3fa0" @@ -2472,6 +2640,10 @@ is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" +is-function@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5" + is-glob@^2.0.0, is-glob@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" @@ -2536,7 +2708,7 @@ is-regex@^1.0.4: dependencies: has "^1.0.1" -is-stream@^1.1.0: +is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -2584,6 +2756,13 @@ isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -2593,27 +2772,20 @@ javascript-natural-sort@0.7.1: resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59" js-base64@^2.1.8, js-base64@^2.1.9: - version "2.1.9" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce" + version "2.3.2" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.3.2.tgz#a79a923666372b580f8e27f51845c6f7e8fbfbaf" js-tokens@^3.0.0, js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" -js-yaml@^3.10.0: +js-yaml@^3.10.0, js-yaml@^3.4.3, js-yaml@^3.9.1: version "3.10.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc" dependencies: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^3.4.3, js-yaml@^3.9.1: - version "3.9.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.9.1.tgz#08775cebdfdd359209f0d2acd383c8f86a6904a0" - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - js-yaml@~3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" @@ -2682,6 +2854,10 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +keycode@^2.1.2: + version "2.1.9" + resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.1.9.tgz#964a23c54e4889405b4861a5c9f0480d45141dfa" + kind-of@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" @@ -2878,14 +3054,14 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" loglevel@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.4.1.tgz#95b383f91a3c2756fd4ab093667e4309161f2bcd" + version "1.5.0" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.5.0.tgz#3863984a2c326b986fbb965f378758a6dc8a4324" longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" -loose-envify@^1.0.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" dependencies: @@ -3055,7 +3231,7 @@ minimist@1.1.x: version "1.1.3" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8" -minimist@^1.1.3, minimist@^1.2.0: +minimist@^1.1.3, minimist@^1.2.0, minimist@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" @@ -3095,6 +3271,13 @@ negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + node-forge@0.6.33: version "0.6.33" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.6.33.tgz#463811879f573d45155ad6a9f43dc296e8e85ebc" @@ -3146,8 +3329,8 @@ node-libs-browser@^2.0.0: vm-browserify "0.0.4" node-pre-gyp@^0.6.36: - version "0.6.36" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.36.tgz#db604112cb74e0d477554e9b505b17abddfab786" + version "0.6.37" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.37.tgz#3c872b236b2e266e4140578fe1ee88f693323a05" dependencies: mkdirp "^0.5.1" nopt "^4.0.1" @@ -3156,6 +3339,7 @@ node-pre-gyp@^0.6.36: request "^2.81.0" rimraf "^2.6.1" semver "^5.3.0" + tape "^4.6.3" tar "^2.2.1" tar-pack "^3.4.0" @@ -3254,6 +3438,10 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" +object-inspect@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.3.0.tgz#5b1eb8e6742e2ee83342a637034d844928ba2f6d" + object-keys@^1.0.8: version "1.0.11" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" @@ -3353,8 +3541,8 @@ p-locate@^2.0.0: p-limit "^1.1.0" p-map@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.1.1.tgz#05f5e4ae97a068371bc2a5cc86bfbdbc19c4ae7a" + version "1.2.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" pako@~0.2.0: version "0.2.9" @@ -3385,9 +3573,9 @@ parse-json@^2.2.0: dependencies: error-ex "^1.2.0" -parseurl@~1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.1.tgz#c8ab8c9223ba34888aa64a297b28853bec18da56" +parseurl@~1.3.1, parseurl@~1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3" path-browserify@0.0.0: version "0.0.0" @@ -3442,8 +3630,8 @@ path-type@^2.0.0: pify "^2.0.0" pbkdf2@^3.0.3: - version "3.0.13" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.13.tgz#c37d295531e786b1da3e3eadc840426accb0ae25" + version "3.0.14" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.14.tgz#a35e13c64799b06ce15320f459c230e68e73bade" dependencies: create-hash "^1.1.2" create-hmac "^1.1.4" @@ -3765,11 +3953,11 @@ postcss-initial@^2.0.0: postcss "^6.0.1" postcss-js@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-1.0.0.tgz#ccee5aa3b1970dd457008e79438165f66919ba30" + version "1.0.1" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-1.0.1.tgz#ffaf29226e399ea74b5dce02cab1729d7addbc7b" dependencies: camelcase-css "^1.0.1" - postcss "^6.0.1" + postcss "^6.0.11" postcss-load-config@^1.2.0: version "1.2.0" @@ -4097,15 +4285,7 @@ postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0 source-map "^0.5.6" supports-color "^3.2.3" -postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.10, postcss@^6.0.2, postcss@^6.0.3, postcss@^6.0.5, postcss@^6.0.6, postcss@^6.0.9: - version "6.0.10" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.10.tgz#c311b89734483d87a91a56dc9e53f15f4e6e84e4" - dependencies: - chalk "^2.1.0" - source-map "^0.5.7" - supports-color "^4.2.1" - -postcss@^6.0.11: +postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.11, postcss@^6.0.2, postcss@^6.0.3, postcss@^6.0.5, postcss@^6.0.6, postcss@^6.0.9: version "6.0.11" resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.11.tgz#f48db210b1d37a7f7ab6499b7a54982997ab6f72" dependencies: @@ -4160,6 +4340,25 @@ promise-each@^2.2.0: dependencies: any-promise "^0.1.0" +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + dependencies: + asap "~2.0.3" + +prop-types-extra@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.0.1.tgz#a57bd4810e82d27a3ff4317ecc1b4ad005f79a82" + dependencies: + warning "^3.0.0" + +prop-types@^15.5.10, prop-types@^15.5.8: + version "15.5.10" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + proxy-addr@~1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.5.tgz#71c0ee3b102de3f202f3b64f608d173fcba1a918" @@ -4261,6 +4460,57 @@ rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-bootstrap@^0.31.0: + version "0.31.3" + resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-0.31.3.tgz#db2b7d45b00b5dac1ab8b6de3dd97feb3091b849" + dependencies: + babel-runtime "^6.11.6" + classnames "^2.2.5" + dom-helpers "^3.2.0" + invariant "^2.2.1" + keycode "^2.1.2" + prop-types "^15.5.10" + prop-types-extra "^1.0.1" + react-overlays "^0.7.0" + react-prop-types "^0.4.0" + uncontrollable "^4.1.0" + warning "^3.0.0" + +react-dom@^15.5.4: + version "15.6.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.6.1.tgz#2cb0ed4191038e53c209eb3a79a23e2a4cf99470" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.1.0" + object-assign "^4.1.0" + prop-types "^15.5.10" + +react-overlays@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.7.0.tgz#531898ff566c7e5c7226ead2863b8cf9fbb5a981" + dependencies: + classnames "^2.2.5" + dom-helpers "^3.2.0" + prop-types "^15.5.8" + react-prop-types "^0.4.0" + warning "^3.0.0" + +react-prop-types@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/react-prop-types/-/react-prop-types-0.4.0.tgz#f99b0bfb4006929c9af2051e7c1414a5c75b93d0" + dependencies: + warning "^3.0.0" + +react@^15.5.4: + version "15.6.1" + resolved "https://registry.yarnpkg.com/react/-/react-15.6.1.tgz#baa8434ec6780bde997cdc380b79cd33b96393df" + dependencies: + create-react-class "^15.6.0" + fbjs "^0.8.9" + loose-envify "^1.1.0" + object-assign "^4.1.0" + prop-types "^15.5.10" + read-cache@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" @@ -4373,8 +4623,8 @@ regex-cache@^0.4.2: is-equal-shallow "^0.1.3" regex-parser@^2.2.1: - version "2.2.7" - resolved "https://registry.yarnpkg.com/regex-parser/-/regex-parser-2.2.7.tgz#bd090e09181849acc45457e765f7be2a63f50ef1" + version "2.2.8" + resolved "https://registry.yarnpkg.com/regex-parser/-/regex-parser-2.2.8.tgz#da4c0cda5a828559094168930f455f532b6ffbac" regexpu-core@^1.0.0: version "1.0.0" @@ -4481,12 +4731,18 @@ resolve-url@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" -resolve@^1.1.7, resolve@^1.3.3: +resolve@^1.1.7, resolve@^1.3.3, resolve@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.4.0.tgz#a75be01c53da25d934a98ebd0e4c4a7312f92a86" dependencies: path-parse "^1.0.5" +resumer@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/resumer/-/resumer-0.0.0.tgz#f1e8f461e4064ba39e82af3cdc2a8c893d076759" + dependencies: + through "~2.3.4" + rework-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/rework-visit/-/rework-visit-1.0.0.tgz#9945b2803f219e2f7aca00adb8bc9f640f842c9a" @@ -4513,8 +4769,8 @@ right-align@^0.1.1: align-text "^0.1.1" rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.1.tgz#c2338ec643df7a1b7fe5c54fa86f57428a55f33d" + version "2.6.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" dependencies: glob "^7.0.5" @@ -4634,7 +4890,7 @@ set-immediate-shim@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -4733,8 +4989,8 @@ source-map-resolve@^0.3.0: urix "~0.1.0" source-map-support@^0.4.15: - version "0.4.17" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.17.tgz#6f2150553e6375375d0ccb3180502b78c18ba430" + version "0.4.18" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" dependencies: source-map "^0.5.6" @@ -4859,6 +5115,14 @@ string-width@^2.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +string.prototype.trim@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz#d04de2c89e137f4d7d206f086b5ed2fae6be8cea" + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.0" + function-bind "^1.0.2" + string_decoder@^0.10.25: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" @@ -4954,6 +5218,24 @@ tapable@^0.2.7: version "0.2.8" resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.8.tgz#99372a5c999bf2df160afc0d74bed4f47948cd22" +tape@^4.6.3: + version "4.8.0" + resolved "https://registry.yarnpkg.com/tape/-/tape-4.8.0.tgz#f6a9fec41cc50a1de50fa33603ab580991f6068e" + dependencies: + deep-equal "~1.0.1" + defined "~1.0.0" + for-each "~0.3.2" + function-bind "~1.1.0" + glob "~7.1.2" + has "~1.0.1" + inherits "~2.0.3" + minimist "~1.2.0" + object-inspect "~1.3.0" + resolve "~1.4.0" + resumer "~0.0.0" + string.prototype.trim "~1.1.2" + through "~2.3.8" + tar-pack@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.0.tgz#23be2d7f671a8339376cbdb0b8fe3fdebf317984" @@ -4979,6 +5261,10 @@ tcomb@^2.5.1: version "2.7.0" resolved "https://registry.yarnpkg.com/tcomb/-/tcomb-2.7.0.tgz#10d62958041669a5d53567b9a4ee8cde22b1c2b0" +through@~2.3.4, through@~2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + thunky@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/thunky/-/thunky-0.1.0.tgz#bf30146824e2b6e67b0f2d7a4ac8beb26908684e" @@ -5048,6 +5334,10 @@ typo-js@*: version "1.0.3" resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.0.3.tgz#54d8ebc7949f1a7810908b6002c6841526c99d5a" +ua-parser-js@^0.7.9: + version "0.7.14" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.14.tgz#110d53fa4c3f326c121292bbeac904d2e03387ca" + uglify-js@^2.8.29: version "2.8.29" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" @@ -5073,6 +5363,12 @@ uid-number@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" +uncontrollable@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-4.1.0.tgz#e0358291252e1865222d90939b19f2f49f81c1a9" + dependencies: + invariant "^2.1.0" + uniq@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" @@ -5178,6 +5474,12 @@ vm-browserify@0.0.4: dependencies: indexof "0.0.1" +warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" + dependencies: + loose-envify "^1.0.0" + watchpack@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac" @@ -5231,14 +5533,7 @@ webpack-dev-server@^2.8.2: webpack-dev-middleware "^1.11.0" yargs "^6.6.0" -webpack-manifest-plugin@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/webpack-manifest-plugin/-/webpack-manifest-plugin-1.3.1.tgz#dc071dd00cc602a014f107436f53a189c0e55a2c" - dependencies: - fs-extra "^0.30.0" - lodash ">=3.5 <5" - -webpack-manifest-plugin@^1.3.2: +webpack-manifest-plugin@^1.3.1, webpack-manifest-plugin@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/webpack-manifest-plugin/-/webpack-manifest-plugin-1.3.2.tgz#5ea8ee5756359ddc1d98814324fe43496349a7d4" dependencies: @@ -5258,36 +5553,9 @@ webpack-sources@^1.0.1: source-list-map "^2.0.0" source-map "~0.5.3" -webpack@^3.5.5: - version "3.5.5" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.5.5.tgz#3226f09fc8b3e435ff781e7af34f82b68b26996c" - dependencies: - acorn "^5.0.0" - acorn-dynamic-import "^2.0.0" - ajv "^5.1.5" - ajv-keywords "^2.0.0" - async "^2.1.2" - enhanced-resolve "^3.4.0" - escope "^3.6.0" - interpret "^1.0.0" - json-loader "^0.5.4" - json5 "^0.5.1" - loader-runner "^2.3.0" - loader-utils "^1.1.0" - memory-fs "~0.4.1" - mkdirp "~0.5.0" - node-libs-browser "^2.0.0" - source-map "^0.5.3" - supports-color "^4.2.1" - tapable "^0.2.7" - uglifyjs-webpack-plugin "^0.4.6" - watchpack "^1.4.0" - webpack-sources "^1.0.1" - yargs "^8.0.2" - -webpack@^3.5.6: - version "3.5.6" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.5.6.tgz#a492fb6c1ed7f573816f90e00c8fbb5a20cc5c36" +webpack@^3.5.5, webpack@^3.5.6: + version "3.6.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.6.0.tgz#a89a929fbee205d35a4fa2cc487be9cbec8898bc" dependencies: acorn "^5.0.0" acorn-dynamic-import "^2.0.0" @@ -5313,14 +5581,19 @@ webpack@^3.5.6: yargs "^8.0.2" websocket-driver@>=0.5.1: - version "0.6.5" - resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" + version "0.7.0" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb" dependencies: + http-parser-js ">=0.4.0" websocket-extensions ">=0.1.1" websocket-extensions@>=0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.1.tgz#76899499c184b6ef754377c2dbb0cd6cb55d29e7" + version "0.1.2" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.2.tgz#0e18781de629a18308ce1481650f67ffa2693a5d" + +whatwg-fetch@>=0.10.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" whet.extend@~0.9.9: version "0.9.9" From 494739e595ffd829799b30efd8f1c66622829137 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 21 Jul 2017 16:06:04 -0700 Subject: [PATCH 04/42] Change select to two radio buttons. --- .../app/javascript/edit-events/modals.jsx | 44 ++++++++++++++----- WcaOnRails/package.json | 2 +- WcaOnRails/yarn.lock | 2 +- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index bdfbaa2a64..24d582a2fe 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -1,8 +1,10 @@ import React from 'react' import ReactDOM from 'react-dom' import Modal from 'react-bootstrap/lib/Modal' +import Radio from 'react-bootstrap/lib/Radio' import Button from 'react-bootstrap/lib/Button' import Checkbox from 'react-bootstrap/lib/Checkbox' +import FormGroup from 'react-bootstrap/lib/FormGroup' import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' @@ -43,6 +45,28 @@ class ButtonActivatedModal extends React.Component { } } +class RadioGroup extends React.Component { + get value() { + let formGroupDom = ReactDOM.findDOMNode(this.formGroup); + return formGroupDom.querySelectorAll('input:checked')[0].value + } + + render() { + return ( + <FormGroup ref={c => this.formGroup = c}> + {this.props.children.map(child => { + return React.cloneElement(child, { + name: this.props.name, + key: child.props.value, + checked: this.props.value == child.props.value, + onChange: this.props.onChange, + }); + })} + </FormGroup> + ); + } +} + class EditRoundAttribute extends React.Component { componentWillMount() { this.reset(); @@ -128,11 +152,11 @@ let RoundAttributeComponents = { otherWcifRounds = otherWcifRounds.concat(wcifEvent.rounds.filter(r => r != wcifRound)); }); - let centisInput, cumulativeInput; + let centisInput, cumulativeInput, cumulativeRadio; let roundCheckboxes = []; let onChangeAggregator = () => { let cumulativeRoundIds; - switch(cumulativeInput.value) { + switch(cumulativeRadio.value) { case "per-solve": cumulativeRoundIds = []; break; @@ -141,7 +165,7 @@ let RoundAttributeComponents = { cumulativeRoundIds = cumulativeRoundIds.concat(roundCheckboxes.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value)); break; default: - throw new Error(`Unrecognized value ${cumulativeInput.value}`); + throw new Error(`Unrecognized value ${cumulativeRadio.value}`); break; } @@ -160,14 +184,14 @@ let RoundAttributeComponents = { value={timeLimit.centiseconds} onChange={onChangeAggregator} /> - <select type="checkbox" - value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} - onChange={onChangeAggregator} - ref={c => cumulativeInput = c} + <RadioGroup value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} + name="cumulative-radio" + onChange={onChangeAggregator} + ref={c => cumulativeRadio = c} > - <option value="per-solve">per solve</option> - <option value="cumulative">cumulative</option> - </select> + <Radio value="per-solve" inline>Per Solve</Radio> + <Radio value="cumulative" inline>Cumulative</Radio> + </RadioGroup> {timeLimit.cumulativeRoundIds.length >= 1 && ( <span> diff --git a/WcaOnRails/package.json b/WcaOnRails/package.json index c3f21a8f94..374659e3af 100644 --- a/WcaOnRails/package.json +++ b/WcaOnRails/package.json @@ -29,7 +29,7 @@ "rails-erb-loader": "^5.0.2", "resolve-url-loader": "^2.1.0", "react": "^15.5.4", - "react-bootstrap": "^0.31.0", + "react-bootstrap": "^0.31.1", "react-dom": "^15.5.4", "sass-loader": "^6.0.6", "simplemde": "^1.11.2", diff --git a/WcaOnRails/yarn.lock b/WcaOnRails/yarn.lock index d92f607386..455075aab1 100644 --- a/WcaOnRails/yarn.lock +++ b/WcaOnRails/yarn.lock @@ -4460,7 +4460,7 @@ rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-bootstrap@^0.31.0: +react-bootstrap@^0.31.1: version "0.31.3" resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-0.31.3.tgz#db2b7d45b00b5dac1ab8b6de3dd97feb3091b849" dependencies: From 88510a903c0c06006e66605ae510d7101abb0487 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 21 Jul 2017 17:29:15 -0700 Subject: [PATCH 05/42] Tweaking dialog titles. --- WcaOnRails/app/javascript/edit-events/modals.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index 24d582a2fe..3ece6de8e0 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -124,7 +124,7 @@ let RoundAttributeComponents = { timeLimit: { Title({ wcifEvent, roundNumber }) { let event = events.byId[wcifEvent.id]; - return <span>Time limit for {event.name} round {roundNumber}</span>; + return <span>Time limit for {event.name}, Round {roundNumber}</span>; }, Show({ value: timeLimit }) { let timeStr = `${(timeLimit.centiseconds / 100 / 60).toFixed(2)} minutes`; @@ -217,7 +217,7 @@ let RoundAttributeComponents = { cutoff: { Title({ wcifEvent, roundNumber }) { let event = events.byId[wcifEvent.id]; - return <span>Cutoff for {event.name} round {roundNumber}</span>; + return <span>Cutoff for {event.name}, Round {roundNumber}</span>; }, Show({ value: cutoff }) { let str; From a771de34a1b76142b47ba4f9ab49117be85d31eb Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 21 Jul 2017 17:30:40 -0700 Subject: [PATCH 06/42] Catching up with latest WCIF spec: "percentile" -> "percent" and "attemptValue" -> "attemptResult" --- .../app/javascript/edit-events/modals.jsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index 3ece6de8e0..4bba48955d 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -284,7 +284,7 @@ let RoundAttributeComponents = { return <span>{str}</span>; }, Input({ value: advancementCondition, onChange, autoFocus }) { - let typeInput, rankingInput, percentileInput, attemptResultInput; + let typeInput, rankingInput, percentInput, attemptResultInput; let onChangeAggregator = () => { let type = typeInput.value; let newAdvancementCondition; @@ -295,16 +295,16 @@ let RoundAttributeComponents = { level: rankingInput ? parseInt(rankingInput.value): 0, }; break; - case "percentile": + case "percent": newAdvancementCondition = { - type: "percentile", - level: percentileInput ? parseInt(percentileInput.value) : 0, + type: "percent", + level: percentInput ? parseInt(percentInput.value) : 0, }; break; - case "attemptValue": + case "attemptResult": newAdvancementCondition = { - type: "attemptValue", - level: attemptResultInput ? parseInt(attemptValue.value) : 0, + type: "attemptResult", + level: attemptResultInput ? parseInt(attemptResult.value) : 0, }; break; default: @@ -324,8 +324,8 @@ let RoundAttributeComponents = { <option value="">TBA</option> <option disabled="disabled">────────</option> <option value="ranking">Ranking</option> - <option value="percentile">Percentile</option> - <option value="attemptValue">Attempt value</option> + <option value="percent">Percent</option> + <option value="attemptResult">Result</option> </select> {advancementCondition && advancementCondition.type == "ranking" && ( @@ -335,14 +335,14 @@ let RoundAttributeComponents = { </span> )} - {advancementCondition && advancementCondition.type == "percentile" && ( + {advancementCondition && advancementCondition.type == "percent" && ( <span> - <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentileInput = c} /> - percentile? + <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} /> + percent? </span> )} - {advancementCondition && advancementCondition.type == "attemptValue" && ( + {advancementCondition && advancementCondition.type == "attemptResult" && ( <span> <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} /> my shirt? From 7183bc9a3f981bf697c65091858496811bf154aa Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 21 Jul 2017 17:31:30 -0700 Subject: [PATCH 07/42] Better support for time limits for 333mbf and 333fm, which do not have changeable time limits. --- WcaOnRails/app/models/event.rb | 4 ++ WcaOnRails/app/models/round.rb | 2 +- .../views/competitions/show_events.html.erb | 8 +-- WcaOnRails/config/locales/en.yml | 2 + .../spec/models/competition_wcif_spec.rb | 53 +++++++++++++++++-- 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/WcaOnRails/app/models/event.rb b/WcaOnRails/app/models/event.rb index f5eda1b3b7..d929c06b5c 100644 --- a/WcaOnRails/app/models/event.rb +++ b/WcaOnRails/app/models/event.rb @@ -55,6 +55,10 @@ def multiple_blindfolded? self.id == "333mbf" || self.id == "333mbo" end + def can_change_time_limit? + !fewest_moves? && !multiple_blindfolded? + end + def serializable_hash(options = nil) { id: self.id, diff --git a/WcaOnRails/app/models/round.rb b/WcaOnRails/app/models/round.rb index 5169611fd9..4c18321ccc 100644 --- a/WcaOnRails/app/models/round.rb +++ b/WcaOnRails/app/models/round.rb @@ -71,7 +71,7 @@ def to_wcif { "id" => "#{event.id}-#{self.number}", "format" => self.format_id, - "timeLimit" => time_limit&.to_wcif, + "timeLimit" => event.can_change_time_limit? ? time_limit&.to_wcif : nil, "cutoff" => cutoff&.to_wcif, "advancementCondition" => advancement_condition&.to_wcif, } diff --git a/WcaOnRails/app/views/competitions/show_events.html.erb b/WcaOnRails/app/views/competitions/show_events.html.erb index 9059d4b1b1..16abf1d93f 100644 --- a/WcaOnRails/app/views/competitions/show_events.html.erb +++ b/WcaOnRails/app/views/competitions/show_events.html.erb @@ -32,12 +32,14 @@ <% competition_event.rounds.each do |round| %> <td><%= round.format.name %></td> <td> - <%= round.time_limit_to_s %> - <% if round.time_limit.cumulative_round_ids %> + <% if !competition_event.event.can_change_time_limit? %> + <%= t "time_limit.#{competition_event.event.id}" %> + <% else %> + <%= round.time_limit_to_s %> <% if round.time_limit.cumulative_round_ids.length == 1 %> <% cumulative_time_limit = true %> <%= link_to "*", "#cumulative-time-limit" %> - <% else %> + <% elsif round.time_limit.cumulative_round_ids.length > 1 %> <% cumulative_across_rounds_time_limit = true %> <%= link_to "**", "#cumulative-across-rounds-time-limit" %> <% end %> diff --git a/WcaOnRails/config/locales/en.yml b/WcaOnRails/config/locales/en.yml index 18d7595d18..7d7bc58d79 100644 --- a/WcaOnRails/config/locales/en.yml +++ b/WcaOnRails/config/locales/en.yml @@ -122,6 +122,8 @@ en: cumulative: one_round: "%{time} cumulative" across_rounds: "%{time} total for %{rounds}" + 333fm: "1 hour" + 333mbf: "10 minutes per cube" #context: Common word used in multiple places on the website common: continent: "Continent" diff --git a/WcaOnRails/spec/models/competition_wcif_spec.rb b/WcaOnRails/spec/models/competition_wcif_spec.rb index cca9f914bb..4a7571c6be 100644 --- a/WcaOnRails/spec/models/competition_wcif_spec.rb +++ b/WcaOnRails/spec/models/competition_wcif_spec.rb @@ -13,7 +13,7 @@ end_date: "2014-02-05", external_website: "http://example.com", showAtAll: true, - event_ids: %w(333 444), + event_ids: %w(333 444 333fm 333mbf), ) } let(:delegate) { competition.delegates.first } @@ -22,6 +22,8 @@ let!(:round333_1) { FactoryGirl.create(:round, competition: competition, event_id: "333", number: 1, cutoff: sixty_second_2_attempt_cutoff, advancement_condition: top_16_advance) } let!(:round333_2) { FactoryGirl.create(:round, competition: competition, event_id: "333", number: 2) } let!(:round444_1) { FactoryGirl.create(:round, competition: competition, event_id: "444", number: 1) } + let!(:round333fm_1) { FactoryGirl.create(:round, competition: competition, event_id: "333fm", number: 1, format_id: "m") } + let!(:round333mbf_1) { FactoryGirl.create(:round, competition: competition, event_id: "333mbf", number: 1, format_id: "3") } before :each do # Load all the rounds we just created. competition.reload @@ -66,6 +68,30 @@ }, ], }, + { + "id" => "333fm", + "rounds" => [ + { + "id" => "333fm-1", + "format" => "m", + "timeLimit" => nil, + "cutoff" => nil, + "advancementCondition" => nil, + }, + ], + }, + { + "id" => "333mbf", + "rounds" => [ + { + "id" => "333mbf-1", + "format" => "3", + "timeLimit" => nil, + "cutoff" => nil, + "advancementCondition" => nil, + }, + ], + }, { "id" => "444", "rounds" => [ @@ -97,7 +123,7 @@ wcif["events"].reject! { |e| e["id"] == "444" } expect(competition.to_wcif["events"]).to eq(wcif["events"]) - expect(competition.events.map(&:id)).to match_array %w(333) + expect(competition.events.map(&:id)).to match_array %w(333 333fm 333mbf) end it "removes competition event when wcif event is missing" do @@ -106,7 +132,7 @@ competition.set_wcif_events!(wcif["events"]) expect(competition.to_wcif["events"]).to eq(wcif["events"]) - expect(competition.events.map(&:id)).to match_array %w(333) + expect(competition.events.map(&:id)).to match_array %w(333 333fm 333mbf) end it "creates competition event when adding round to previously nonexistent event" do @@ -169,5 +195,26 @@ expect(competition.to_wcif["events"]).to eq(wcif["events"]) end + + it "ignores setting time limit for 333mbf and 333fm" do + wcif_333mbf_event = wcif["events"].find { |e| e["id"] == "333mbf" } + wcif_333mbf_event["rounds"][0]["timeLimit"] = { + "centiseconds" => 30.minutes.in_centiseconds, + "cumulativeRoundIds" => [], + } + + wcif_333fm_event = wcif["events"].find { |e| e["id"] == "333fm" } + wcif_333fm_event["rounds"][0]["timeLimit"] = { + "centiseconds" => 30.minutes.in_centiseconds, + "cumulativeRoundIds" => [], + } + + competition.set_wcif_events!(wcif["events"]) + + wcif_333mbf_event["rounds"][0]["timeLimit"] = nil + wcif_333fm_event["rounds"][0]["timeLimit"] = nil + + expect(competition.to_wcif["events"]).to eq(wcif["events"]) + end end end From 016cfc2dad031ed416b4a5583ff23063e2c35f4e Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 21 Jul 2017 17:38:29 -0700 Subject: [PATCH 08/42] Don't display buttons to edit the time limit for events that do not support an editable time limit. --- WcaOnRails/app/javascript/edit-events/EditEvents.jsx | 11 +++++++---- WcaOnRails/app/models/event.rb | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index 9f529741bb..3fd4215d37 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -84,6 +84,7 @@ export default class EditEvents extends React.Component { function RoundsTable({ wcifEvents, wcifEvent }) { let event = events.byId[wcifEvent.id]; + let canChangeTimeLimit = event.can_change_time_limit; return ( <div className="table-responsive"> <table className="table table-condensed"> @@ -91,7 +92,7 @@ function RoundsTable({ wcifEvents, wcifEvent }) { <tr> <th>#</th> <th className="text-center">Format</th> - <th className="text-center">Time Limit</th> + {canChangeTimeLimit && <th className="text-center">Time Limit</th>} <th className="text-center">Cutoff</th> <th className="text-center">To Advance</th> </tr> @@ -116,9 +117,11 @@ function RoundsTable({ wcifEvents, wcifEvent }) { </select> </td> - <td className="text-center"> - <EditTimeLimitButton wcifEvents={wcifEvents} wcifEvent={wcifEvent} roundNumber={roundNumber} /> - </td> + {canChangeTimeLimit && ( + <td className="text-center"> + <EditTimeLimitButton wcifEvents={wcifEvents} wcifEvent={wcifEvent} roundNumber={roundNumber} /> + </td> + )} <td className="text-center"> <EditCutoffButton wcifEvents={wcifEvents} wcifEvent={wcifEvent} roundNumber={roundNumber} /> diff --git a/WcaOnRails/app/models/event.rb b/WcaOnRails/app/models/event.rb index d929c06b5c..6e0d5d5d9f 100644 --- a/WcaOnRails/app/models/event.rb +++ b/WcaOnRails/app/models/event.rb @@ -64,6 +64,7 @@ def serializable_hash(options = nil) id: self.id, name: self.name, format_ids: self.formats.map(&:id), + can_change_time_limit: self.can_change_time_limit?, } end end From fc4ca877d9e6864c9771a44c763e63d8750b9e3c Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 24 Aug 2017 09:35:53 -0700 Subject: [PATCH 09/42] Explicitly disable the edit event form when there are deprecated events. I'm open to adding support for this in the future if it is deemed necessary, but for not, it seems like unecessary work. --- .../views/competitions/edit_events.html.erb | 22 +++++++++++-------- WcaOnRails/config/locales/en.yml | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/WcaOnRails/app/views/competitions/edit_events.html.erb b/WcaOnRails/app/views/competitions/edit_events.html.erb index a9696e11ed..f1d5c28444 100644 --- a/WcaOnRails/app/views/competitions/edit_events.html.erb +++ b/WcaOnRails/app/views/competitions/edit_events.html.erb @@ -2,13 +2,17 @@ <%= javascript_pack_tag 'edit_events' %> <%= render layout: 'nav' do %> - <div id="events-edit-area"></div> - <script> - $(function() { - wca.initializeEventsForm( - <%= @competition.id.to_json.html_safe %>, - <%= @competition.competition_events.map(&:to_wcif).to_json.html_safe %> - ); - }); - </script> + <% if @competition.events.deprecated.count > 0 %> + <%= t('competitions.competition_form.deprecated_events') %> + <% else %> + <div id="events-edit-area"></div> + <script> + $(function() { + wca.initializeEventsForm( + <%= @competition.id.to_json.html_safe %>, + <%= @competition.competition_events.map(&:to_wcif).to_json.html_safe %> + ); + }); + </script> + <% end %> <% end %> diff --git a/WcaOnRails/config/locales/en.yml b/WcaOnRails/config/locales/en.yml index 7d7bc58d79..18ca772374 100644 --- a/WcaOnRails/config/locales/en.yml +++ b/WcaOnRails/config/locales/en.yml @@ -873,7 +873,7 @@ en: venue_details_html: "Details about the venue (e.g., On the first floor far in the back, follow the signs). %{md}" contact_html: "Optional contact information. If you do not fill this in, organizer emails will be shown to the public. %{md}. Example: [Text to display](mailto:some@email.com)" events: "Events" - unoff_events: "Events that are no longer official" + deprecated_events: "This competition has events that are no longer official, we do not support editing them." locked_edit_html: "This competition is publicly visible and locked for editing. If you need to make a change, contact the %{board}." confirmed_not_visible_html: "You've confirmed this competition, but it is not yet visible to the public. Wait for the %{board} to make it visible." is_visible: "This competition is publicly visible, any changes you make will show up to the public!" From c021ecf8f9df7a15377ae3c32f5e922de5da8a5b Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 24 Aug 2017 10:23:17 -0700 Subject: [PATCH 10/42] Remove old test that no longer applies. --- WcaOnRails/spec/features/competition_management_spec.rb | 9 --------- 1 file changed, 9 deletions(-) diff --git a/WcaOnRails/spec/features/competition_management_spec.rb b/WcaOnRails/spec/features/competition_management_spec.rb index 683af6f1bb..ea790850d3 100644 --- a/WcaOnRails/spec/features/competition_management_spec.rb +++ b/WcaOnRails/spec/features/competition_management_spec.rb @@ -150,15 +150,6 @@ feature "edit" do let(:comp_with_fours) { FactoryGirl.create :competition, events: [fours], delegates: [delegate] } - scenario 'can edit events' do - visit edit_events_path(comp_with_fours) - check "competition_events_333" - uncheck "competition_events_444" - click_button "Modify Events" - - expect(comp_with_fours.reload.events).to match_array [threes] - end - scenario 'can edit registration open datetime', js: true do visit edit_competition_path(comp_with_fours) check "competition_use_wca_registration" From 31ac1b9e68ce0b813f57eb64e03cd900f5cb42fd Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Mon, 28 Aug 2017 15:17:10 -0700 Subject: [PATCH 11/42] Clean up the advacement dialog a bit. --- .../app/assets/stylesheets/edit_events.scss | 8 ++ .../app/javascript/edit-events/modals.jsx | 78 ++++++++++--------- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/WcaOnRails/app/assets/stylesheets/edit_events.scss b/WcaOnRails/app/assets/stylesheets/edit_events.scss index 6f0c2228bc..f912dcc0d4 100644 --- a/WcaOnRails/app/assets/stylesheets/edit_events.scss +++ b/WcaOnRails/app/assets/stylesheets/edit_events.scss @@ -28,3 +28,11 @@ } } } + +.input-group.advancement-condition { + width: 100%; + + .form-control { + width: 50%; + } +} diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index 4bba48955d..e7d4f6e3f5 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -283,7 +283,7 @@ let RoundAttributeComponents = { let str = advanceReqToStr(advancementCondition); return <span>{str}</span>; }, - Input({ value: advancementCondition, onChange, autoFocus }) { + Input({ value: advancementCondition, onChange, autoFocus, roundNumber }) { let typeInput, rankingInput, percentInput, attemptResultInput; let onChangeAggregator = () => { let type = typeInput.value; @@ -304,7 +304,7 @@ let RoundAttributeComponents = { case "attemptResult": newAdvancementCondition = { type: "attemptResult", - level: attemptResultInput ? parseInt(attemptResult.value) : 0, + level: attemptResultInput ? parseInt(attemptResultInput.value) : 0, }; break; default: @@ -314,41 +314,49 @@ let RoundAttributeComponents = { onChange(newAdvancementCondition); }; - return ( - <span> - <select value={advancementCondition ? advancementCondition.type : ""} - autoFocus={autoFocus} - onChange={onChangeAggregator} - ref={c => typeInput = c} - > - <option value="">TBA</option> - <option disabled="disabled">────────</option> - <option value="ranking">Ranking</option> - <option value="percent">Percent</option> - <option value="attemptResult">Result</option> - </select> - - {advancementCondition && advancementCondition.type == "ranking" && ( - <span> - <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} /> - ranking? - </span> - )} + let advancementInput = null; + let helpBlock = null; + let advancementType = advancementCondition ? advancementCondition.type : ""; + switch(advancementType) { + case "ranking": + advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} />; + helpBlock = `The top ${advancementCondition.level} competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; + break; + case "percent": + advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} />; + helpBlock = `The top ${advancementCondition.level}% of competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; + break; + case "attemptResult": + advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} />; + helpBlock = `Everyone in round ${roundNumber} with a result better than or equal to ${advancementCondition.level} will advance to round ${roundNumber + 1}.`; + break; + default: + advancementInput = null; + break; + } - {advancementCondition && advancementCondition.type == "percent" && ( - <span> - <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} /> - percent? - </span> - )} + return ( + <div> + <div className="form-group"> + <div className="input-group advancement-condition"> + <select value={advancementCondition ? advancementCondition.type : ""} + autoFocus={autoFocus} + onChange={onChangeAggregator} + className="form-control" + ref={c => typeInput = c} + > + <option value="">To be announced</option> + <option disabled="disabled">────────</option> + <option value="ranking">Ranking</option> + <option value="percent">Percent</option> + <option value="attemptResult">Result</option> + </select> - {advancementCondition && advancementCondition.type == "attemptResult" && ( - <span> - <input type="number" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} /> - my shirt? - </span> - )} - </span> + {advancementInput} + </div> + </div> + {helpBlock} + </div> ); }, }, From 109cdda395177b026f371ecea3c020625f549586 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Mon, 28 Aug 2017 16:29:35 -0700 Subject: [PATCH 12/42] Add DISABLE_BULLET environment variable. --- WcaOnRails/Envfile | 4 ++++ WcaOnRails/config/environments/development.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/WcaOnRails/Envfile b/WcaOnRails/Envfile index 43f36f4e92..a807c3ffa7 100644 --- a/WcaOnRails/Envfile +++ b/WcaOnRails/Envfile @@ -9,6 +9,10 @@ group :production do variable :SMTP_PASSWORD end +group :development do + variable :DISABLE_BULLET, :boolean, default: false +end + # Set WCA_LIVE_SITE to enable Google Analytics # and allow all on robots.txt. variable :WCA_LIVE_SITE, :boolean, default: false diff --git a/WcaOnRails/config/environments/development.rb b/WcaOnRails/config/environments/development.rb index b569c373f4..5dcde677e0 100644 --- a/WcaOnRails/config/environments/development.rb +++ b/WcaOnRails/config/environments/development.rb @@ -67,7 +67,7 @@ # config.action_view.raise_on_missing_translations = true config.after_initialize do - Bullet.enable = true + Bullet.enable = !ENVied.DISABLE_BULLET Bullet.alert = true Bullet.bullet_logger = true Bullet.console = true From a2e27a331181ffac71f3b8f4c374485db099672f Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Mon, 28 Aug 2017 16:30:48 -0700 Subject: [PATCH 13/42] Start of support for timed events vs fmc vs mbld. --- .../app/javascript/edit-events/modals.jsx | 21 +++++++++++++++---- WcaOnRails/app/models/event.rb | 3 +++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index e7d4f6e3f5..51045d011c 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -10,6 +10,19 @@ import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' import { rootRender } from 'edit-events' +function attemptResultToString(attemptResult, eventId) { + let event = events.byId[eventId]; + if(event.timed_event) { + return `${attemptResult} centiseconds`; // TODO <<<>>> + } else if(event.fewest_moves) { + return `${attemptResult} moves`; + } else if(event.multiple_blindfolded) { + return `${attemptResult} points`; // TODO <<<>>> + } else { + throw new Error(`Unrecognized event type: ${eventId}`); + } +} + class ButtonActivatedModal extends React.Component { constructor() { super(); @@ -219,10 +232,10 @@ let RoundAttributeComponents = { let event = events.byId[wcifEvent.id]; return <span>Cutoff for {event.name}, Round {roundNumber}</span>; }, - Show({ value: cutoff }) { + Show({ value: cutoff, wcifEvent }) { let str; if(cutoff) { - str = `better than or equal to ${cutoff.attemptResult} in ${cutoff.numberOfAttempts}`; + str = `better than or equal to ${attemptResultToString(cutoff.attemptResult, wcifEvent.id)} in ${cutoff.numberOfAttempts}`; } else { str = "-"; } @@ -283,7 +296,7 @@ let RoundAttributeComponents = { let str = advanceReqToStr(advancementCondition); return <span>{str}</span>; }, - Input({ value: advancementCondition, onChange, autoFocus, roundNumber }) { + Input({ value: advancementCondition, onChange, autoFocus, roundNumber, wcifEvent }) { let typeInput, rankingInput, percentInput, attemptResultInput; let onChangeAggregator = () => { let type = typeInput.value; @@ -328,7 +341,7 @@ let RoundAttributeComponents = { break; case "attemptResult": advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} />; - helpBlock = `Everyone in round ${roundNumber} with a result better than or equal to ${advancementCondition.level} will advance to round ${roundNumber + 1}.`; + helpBlock = `Everyone in round ${roundNumber} with a result better than or equal to ${attemptResultToString(advancementCondition.level, wcifEvent.id)} will advance to round ${roundNumber + 1}.`; break; default: advancementInput = null; diff --git a/WcaOnRails/app/models/event.rb b/WcaOnRails/app/models/event.rb index 6e0d5d5d9f..c8dfa24f12 100644 --- a/WcaOnRails/app/models/event.rb +++ b/WcaOnRails/app/models/event.rb @@ -65,6 +65,9 @@ def serializable_hash(options = nil) name: self.name, format_ids: self.formats.map(&:id), can_change_time_limit: self.can_change_time_limit?, + timed_event: self.timed_event?, + fewest_moves: self.fewest_moves?, + multiple_blindfolded: self.multiple_blindfolded?, } end end From 268abe44fb7b2041a05d66685cd74d428da189a8 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Mon, 28 Aug 2017 16:33:52 -0700 Subject: [PATCH 14/42] Implemented format1 / format2 ui for the cutoff dialog. --- .../app/javascript/edit-events/modals.jsx | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index 51045d011c..bb433f4157 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -44,7 +44,7 @@ class ButtonActivatedModal extends React.Component { onClick={this.open}> {this.props.buttonValue} <Modal show={this.state.showModal} onHide={this.close}> - <form onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> + <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> {this.props.children} <Modal.Footer> <Button onClick={this.close} className="pull-left">Close</Button> @@ -110,14 +110,14 @@ class EditRoundAttribute extends React.Component { render() { let { wcifEvents, wcifEvent, roundNumber } = this.props; - let wcifRound = this.getWcifRound(); let Show = RoundAttributeComponents[this.props.attribute].Show; let Input = RoundAttributeComponents[this.props.attribute].Input; let Title = RoundAttributeComponents[this.props.attribute].Title; return ( <ButtonActivatedModal - buttonValue={<Show value={this.getSavedValue()} />} + buttonValue={<Show value={this.getSavedValue()} wcifEvent={wcifEvent} />} + formClass="form-horizontal" onSave={this.onSave} reset={this.reset} ref={c => this._modal = c} @@ -241,7 +241,9 @@ let RoundAttributeComponents = { } return <span>{str}</span>; }, - Input({ value: cutoff, onChange, autoFocus }) { + Input({ value: cutoff, onChange, autoFocus, wcifEvent, roundNumber }) { + let wcifRound = wcifEvent.rounds[roundNumber - 1]; + let numberOfAttemptsInput, attemptResultInput; let onChangeAggregator = () => { let numberOfAttempts = parseInt(numberOfAttemptsInput.value); @@ -258,29 +260,45 @@ let RoundAttributeComponents = { }; return ( - <span> - <select value={cutoff ? cutoff.numberOfAttempts : 0} - autoFocus={autoFocus} - onChange={onChangeAggregator} - ref={c => numberOfAttemptsInput = c} - > - <option value={0}>No cutoff</option> - <option disabled="disabled">────────</option> - <option value={1}>1 attempt</option> - <option value={2}>2 attempts</option> - <option value={3}>3 attempts</option> - </select> + <div> + <div className="form-group"> + <label htmlFor="cutoff-round-format-input" className="col-sm-3 control-label">Round format</label> + <div className="col-sm-9"> + <div className="input-group"> + <select value={cutoff ? cutoff.numberOfAttempts : 0} + autoFocus={autoFocus} + onChange={onChangeAggregator} + className="form-control" + id="cutoff-round-format-input" + ref={c => numberOfAttemptsInput = c} + > + <option value={0}>No cutoff</option> + <option disabled="disabled">────────</option> + <option value={1}>Best of 1</option> + <option value={2}>Best of 2</option> + <option value={3}>Best of 3</option> + </select> + <div className="input-group-addon"> + <strong>/ {formats.byId[wcifRound.format].name}</strong> + </div> + </div> + </div> + </div> {cutoff && ( - <span> - {" "}to get better than or equal to{" "} - <input type="number" - value={cutoff.attemptResult} - onChange={onChangeAggregator} - ref={c => attemptResultInput = c} - /> - </span> + <div className="form-group"> + <label htmlFor="cutoff-input" className="col-sm-3 control-label">Cutoff</label> + <div className="col-sm-9"> + <input type="number" + className="form-control" + id="cutoff-input" + value={cutoff.attemptResult} + onChange={onChangeAggregator} + ref={c => attemptResultInput = c} + /> + </div> + </div> )} - </span> + </div> ); }, }, From 9ad390ed2cc4cb0544a2bcdc73ac50ebe4025dc3 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Mon, 28 Aug 2017 16:34:36 -0700 Subject: [PATCH 15/42] Set backdrop static to prevent dismissing dialogs when clicking on the background. --- WcaOnRails/app/javascript/edit-events/modals.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index bb433f4157..59692c12d6 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -43,7 +43,7 @@ class ButtonActivatedModal extends React.Component { <button type="button" className="btn btn-default btn-xs" onClick={this.open}> {this.props.buttonValue} - <Modal show={this.state.showModal} onHide={this.close}> + <Modal show={this.state.showModal} onHide={this.close} backdrop="static"> <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> {this.props.children} <Modal.Footer> From a4876b679298beebeaa59b5c17d3667fb67fbf72 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Mon, 28 Aug 2017 17:33:56 -0700 Subject: [PATCH 16/42] Add a helpful description for the weird cumulative time limits. --- .../app/javascript/edit-events/modals.jsx | 107 ++++++++++++------ 1 file changed, 74 insertions(+), 33 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index 59692c12d6..a220b94f15 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -10,10 +10,14 @@ import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' import { rootRender } from 'edit-events' +function centisecondsToString(centiseconds) { + return `${centiseconds / 100} seconds`; // TODO <<< >>> +} + function attemptResultToString(attemptResult, eventId) { let event = events.byId[eventId]; if(event.timed_event) { - return `${attemptResult} centiseconds`; // TODO <<<>>> + return centisecondsToString(attemptResult); } else if(event.fewest_moves) { return `${attemptResult} moves`; } else if(event.multiple_blindfolded) { @@ -188,42 +192,79 @@ let RoundAttributeComponents = { }; onChange(newTimeLimit); }; + + let description = null; + if(timeLimit.cumulativeRoundIds.length === 0) { + description = `Competitors have ${timeLimit.centiseconds} centiseconds for each of their solves.`; + } else if(timeLimit.cumulativeRoundIds.length === 1) { + description = (<span> + Competitors have {centisecondsToString(timeLimit.centiseconds)} total for all + of their solves in this round. This is called a cumulative time limit, defined in + regulation <a href="https://www.worldcubeassociation.org/regulations/#A1a2" target="_blank">A1a2</a>. + </span>); + } else { + let otherSelectedRoundIds = timeLimit.cumulativeRoundIds.filter(roundId => roundId != wcifRound.id); + description = (<span> + Competitors have {centisecondsToString(timeLimit.centiseconds)} total for all + of their solves in this round + {" "}<strong>and round(s) {otherSelectedRoundIds.join(", ")}</strong>. + This is called a cross round cumulative time limit, see + guideline <a href="https://www.worldcubeassociation.org/regulations/guidelines.html#A1a2++" target="_blank">A1a2++</a>. + </span>); + } + return ( - <span> - centis - <input type="number" - autoFocus={autoFocus} - ref={c => centisInput = c} - value={timeLimit.centiseconds} - onChange={onChangeAggregator} /> - - <RadioGroup value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} - name="cumulative-radio" - onChange={onChangeAggregator} - ref={c => cumulativeRadio = c} - > - <Radio value="per-solve" inline>Per Solve</Radio> - <Radio value="cumulative" inline>Cumulative</Radio> - </RadioGroup> + <div> + <div className="form-group"> + <label htmlFor="time-limit-input" className="col-sm-3 control-label">Time</label> + <div className="col-sm-9"> + <input type="number" + id="time-limit-input" + className="form-control" + autoFocus={autoFocus} + ref={c => centisInput = c} + value={timeLimit.centiseconds} + onChange={onChangeAggregator} /> + </div> + </div> + + <div className="row"> + <div className="col-sm-offset-3 col-sm-9"> + <RadioGroup value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} + name="cumulative-radio" + onChange={onChangeAggregator} + ref={c => cumulativeRadio = c} + > + <Radio value="per-solve" inline>Per Solve</Radio> + <Radio value="cumulative" inline>Cumulative</Radio> + </RadioGroup> + </div> + </div> {timeLimit.cumulativeRoundIds.length >= 1 && ( - <span> - {otherWcifRounds.map(wcifRound => { - let roundId = wcifRound.id; - return ( - <label key={roundId}> - <input type="checkbox" - value={roundId} - checked={timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0} - ref={c => roundCheckboxes.push(c) } - onChange={onChangeAggregator} /> - {roundId} - </label> - ); - })} - </span> + <div className="row"> + <div className="col-sm-offset-3 col-sm-9"> + {otherWcifRounds.map(wcifRound => { + let roundId = wcifRound.id; + return ( + <label key={roundId}> + <input type="checkbox" + value={roundId} + checked={timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0} + ref={c => roundCheckboxes.push(c) } + onChange={onChangeAggregator} /> + {roundId} + </label> + ); + })} + </div> + </div> )} - </span> + + <div className="row"> + <span className="col-sm-offset-3 col-sm-9">{description}</span> + </div> + </div> ); }, }, From 65fed780097584e14b8b100afa379e256eeede41 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Mon, 28 Aug 2017 17:43:11 -0700 Subject: [PATCH 17/42] Misc styling. --- .../app/javascript/edit-events/modals.jsx | 115 +++++++++++------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index a220b94f15..8f8c30be43 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -4,14 +4,31 @@ import Modal from 'react-bootstrap/lib/Modal' import Radio from 'react-bootstrap/lib/Radio' import Button from 'react-bootstrap/lib/Button' import Checkbox from 'react-bootstrap/lib/Checkbox' -import FormGroup from 'react-bootstrap/lib/FormGroup' import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' import { rootRender } from 'edit-events' +function roundIdToString(roundId) { + let [ eventId, roundNumber ] = roundId.split("-"); + roundNumber = parseInt(roundNumber); + let event = events.byId[eventId]; + return `${event.name}, Round ${roundNumber}`; +} + function centisecondsToString(centiseconds) { - return `${centiseconds / 100} seconds`; // TODO <<< >>> + const seconds = centiseconds / 100; + const minutes = seconds / 60; + const hours = minutes / 60; + + // TODO <<< >>> + if(hours >= 1) { + return `${hours.toFixed(2)} hours`; + } else if(minutes >= 1) { + return `${minutes.toFixed(2)} minutes`; + } else { + return `${seconds.toFixed(2)} seconds`; + } } function attemptResultToString(attemptResult, eventId) { @@ -70,7 +87,7 @@ class RadioGroup extends React.Component { render() { return ( - <FormGroup ref={c => this.formGroup = c}> + <div ref={c => this.formGroup = c}> {this.props.children.map(child => { return React.cloneElement(child, { name: this.props.name, @@ -79,7 +96,7 @@ class RadioGroup extends React.Component { onChange: this.props.onChange, }); })} - </FormGroup> + </div> ); } } @@ -114,6 +131,7 @@ class EditRoundAttribute extends React.Component { render() { let { wcifEvents, wcifEvent, roundNumber } = this.props; + let wcifRound = this.getWcifRound(); let Show = RoundAttributeComponents[this.props.attribute].Show; let Input = RoundAttributeComponents[this.props.attribute].Input; let Title = RoundAttributeComponents[this.props.attribute].Title; @@ -127,7 +145,7 @@ class EditRoundAttribute extends React.Component { ref={c => this._modal = c} > <Modal.Header closeButton> - <Modal.Title><Title wcifEvent={wcifEvent} roundNumber={roundNumber} /></Modal.Title> + <Modal.Title><Title wcifRound={wcifRound} /></Modal.Title> </Modal.Header> <Modal.Body> <Input value={this.state.value} wcifEvents={wcifEvents} wcifEvent={wcifEvent} roundNumber={roundNumber} onChange={this.onChange} autoFocus /> @@ -139,12 +157,11 @@ class EditRoundAttribute extends React.Component { let RoundAttributeComponents = { timeLimit: { - Title({ wcifEvent, roundNumber }) { - let event = events.byId[wcifEvent.id]; - return <span>Time limit for {event.name}, Round {roundNumber}</span>; + Title({ wcifRound }) { + return <span>Time limit for {roundIdToString(wcifRound.id)}</span>; }, Show({ value: timeLimit }) { - let timeStr = `${(timeLimit.centiseconds / 100 / 60).toFixed(2)} minutes`; + let timeStr = centisecondsToString(timeLimit.centiseconds); let str; switch(timeLimit.cumulativeRoundIds.length) { case 0: @@ -165,8 +182,14 @@ let RoundAttributeComponents = { let format = formats.byId[wcifRound.format]; let otherWcifRounds = []; - wcifEvents.forEach(wcifEvent => { - otherWcifRounds = otherWcifRounds.concat(wcifEvent.rounds.filter(r => r != wcifRound)); + wcifEvents.forEach(otherWcifEvent => { + // Cross round cumulative time limits may not include other rounds of + // the same event. + // See https://github.com/thewca/wca-regulations/issues/457. + if(wcifEvent == otherWcifEvent) { + return; + } + otherWcifRounds = otherWcifRounds.concat(otherWcifEvent.rounds.filter(r => r != wcifRound)); }); let centisInput, cumulativeInput, cumulativeRadio; @@ -195,7 +218,7 @@ let RoundAttributeComponents = { let description = null; if(timeLimit.cumulativeRoundIds.length === 0) { - description = `Competitors have ${timeLimit.centiseconds} centiseconds for each of their solves.`; + description = `Competitors have ${centisecondsToString(timeLimit.centiseconds)} for each of their solves.`; } else if(timeLimit.cumulativeRoundIds.length === 1) { description = (<span> Competitors have {centisecondsToString(timeLimit.centiseconds)} total for all @@ -205,19 +228,22 @@ let RoundAttributeComponents = { } else { let otherSelectedRoundIds = timeLimit.cumulativeRoundIds.filter(roundId => roundId != wcifRound.id); description = (<span> - Competitors have {centisecondsToString(timeLimit.centiseconds)} total for all - of their solves in this round - {" "}<strong>and round(s) {otherSelectedRoundIds.join(", ")}</strong>. - This is called a cross round cumulative time limit, see + This round has a cross round cumulative time limit, see guideline <a href="https://www.worldcubeassociation.org/regulations/guidelines.html#A1a2++" target="_blank">A1a2++</a>. + This means that competitors have {centisecondsToString(timeLimit.centiseconds)} total for all + of their solves in this round ({wcifRound.id}) + {" "}<strong>shared with</strong>: + <ul> + {otherSelectedRoundIds.map(roundId => <li key={roundId}>{roundIdToString(roundId)}</li>)} + </ul> </span>); } return ( <div> <div className="form-group"> - <label htmlFor="time-limit-input" className="col-sm-3 control-label">Time</label> - <div className="col-sm-9"> + <label htmlFor="time-limit-input" className="col-sm-2 control-label">Time</label> + <div className="col-sm-10"> <input type="number" id="time-limit-input" className="form-control" @@ -228,8 +254,8 @@ let RoundAttributeComponents = { </div> </div> - <div className="row"> - <div className="col-sm-offset-3 col-sm-9"> + <div className="form-group"> + <div className="col-sm-offset-2 col-sm-10"> <RadioGroup value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} name="cumulative-radio" onChange={onChangeAggregator} @@ -243,7 +269,7 @@ let RoundAttributeComponents = { {timeLimit.cumulativeRoundIds.length >= 1 && ( <div className="row"> - <div className="col-sm-offset-3 col-sm-9"> + <div className="col-sm-offset-2 col-sm-10"> {otherWcifRounds.map(wcifRound => { let roundId = wcifRound.id; return ( @@ -253,7 +279,7 @@ let RoundAttributeComponents = { checked={timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0} ref={c => roundCheckboxes.push(c) } onChange={onChangeAggregator} /> - {roundId} + {roundIdToString(roundId)} </label> ); })} @@ -262,16 +288,15 @@ let RoundAttributeComponents = { )} <div className="row"> - <span className="col-sm-offset-3 col-sm-9">{description}</span> + <span className="col-sm-offset-2 col-sm-10">{description}</span> </div> </div> ); }, }, cutoff: { - Title({ wcifEvent, roundNumber }) { - let event = events.byId[wcifEvent.id]; - return <span>Cutoff for {event.name}, Round {roundNumber}</span>; + Title({ wcifRound }) { + return <span>Cutoff for {roundIdToString(wcifRound.id)}</span>; }, Show({ value: cutoff, wcifEvent }) { let str; @@ -344,12 +369,12 @@ let RoundAttributeComponents = { }, }, advancementCondition: { - Title({ wcifEvent, roundNumber }) { - let event = events.byId[wcifEvent.id]; - return <span>Requirement to advance from {event.name} round {roundNumber} to round {roundNumber + 1}</span>; + Title({ wcifRound }) { + return <span>Requirement to advance past {roundIdToString(wcifRound.id)}</span>; }, Show({ value: advancementCondition }) { function advanceReqToStr(advancementCondition) { + // TODO <<< >>> return advancementCondition ? `${advancementCondition.type} ${advancementCondition.level}` : "-"; } let str = advanceReqToStr(advancementCondition); @@ -410,21 +435,23 @@ let RoundAttributeComponents = { return ( <div> <div className="form-group"> - <div className="input-group advancement-condition"> - <select value={advancementCondition ? advancementCondition.type : ""} - autoFocus={autoFocus} - onChange={onChangeAggregator} - className="form-control" - ref={c => typeInput = c} - > - <option value="">To be announced</option> - <option disabled="disabled">────────</option> - <option value="ranking">Ranking</option> - <option value="percent">Percent</option> - <option value="attemptResult">Result</option> - </select> - - {advancementInput} + <div className="col-sm-12"> + <div className="input-group advancement-condition"> + <select value={advancementCondition ? advancementCondition.type : ""} + autoFocus={autoFocus} + onChange={onChangeAggregator} + className="form-control" + ref={c => typeInput = c} + > + <option value="">To be announced</option> + <option disabled="disabled">────────</option> + <option value="ranking">Ranking</option> + <option value="percent">Percent</option> + <option value="attemptResult">Result</option> + </select> + + {advancementInput} + </div> </div> </div> {helpBlock} From c464071e2a71c62b32dab304b5482e87043787cf Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Mon, 28 Aug 2017 19:12:14 -0700 Subject: [PATCH 18/42] Actually added support for cross round cumulative time limits. --- .../app/javascript/edit-events/modals.jsx | 107 +++++++++++++++--- 1 file changed, 90 insertions(+), 17 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx index 8f8c30be43..790d98d18d 100644 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals.jsx @@ -101,11 +101,43 @@ class RadioGroup extends React.Component { } } +function findRoundsSharingTimeLimitWithRound(wcifEvents, wcifRound) { + let roundsSharingTimeLimit = []; + wcifEvents.forEach(otherWcifEvent => { + otherWcifEvent.rounds.forEach(otherWcifRound => { + if(otherWcifRound == wcifRound || !otherWcifRound.timeLimit) { + return; + } + + if(otherWcifRound.timeLimit.cumulativeRoundIds.indexOf(wcifRound.id) >= 0) { + roundsSharingTimeLimit.push(otherWcifRound); + } + }); + }); + return roundsSharingTimeLimit; +} + +function findRounds(wcifEvents, roundIds) { + let wcifRounds = []; + wcifEvents.forEach(wcifEvent => { + wcifEvent.rounds.forEach(wcifRound => { + if(roundIds.indexOf(wcifRound.id) >= 0) { + wcifRounds.push(wcifRound); + } + }); + }); + return wcifRounds; +} + class EditRoundAttribute extends React.Component { componentWillMount() { this.reset(); } + componentWillReceiveProps() { + this.reset(); + } + getWcifRound() { let { wcifEvent, roundNumber } = this.props; return wcifEvent.rounds[roundNumber - 1]; @@ -120,7 +152,33 @@ class EditRoundAttribute extends React.Component { } onSave = () => { - this.getWcifRound()[this.props.attribute] = this.state.value; + let wcifRound = this.getWcifRound(); + wcifRound[this.props.attribute] = this.state.value; + + // This is gross. timeLimit is special because of cross round cumulative time limits. + // If you set a time limit for 3x3x3 round 1 shared with 2x2x2 round 1, then we need + // to make sure the same timeLimit gets set for both of the rounds. + if(this.props.attribute == "timeLimit") { + let timeLimit = this.state.value; + + // First, remove this round from all other rounds that previously shared + // a time limit with this round. + findRoundsSharingTimeLimitWithRound(this.props.wcifEvents, wcifRound).forEach(otherWcifRound => { + let index = otherWcifRound.timeLimit.cumulativeRoundIds.indexOf(wcifRound.id); + if(index < 0) { + throw new Error(); + } + otherWcifRound.timeLimit.cumulativeRoundIds.splice(index, 1); + }); + + // Second, clobber the time limits for all rounds that this round now shares a time limit with. + if(timeLimit) { + findRounds(this.props.wcifEvents, timeLimit.cumulativeRoundIds).forEach(wcifRound => { + wcifRound.timeLimit = timeLimit; + }); + } + } + this._modal.close(); rootRender(); } @@ -186,7 +244,9 @@ let RoundAttributeComponents = { // Cross round cumulative time limits may not include other rounds of // the same event. // See https://github.com/thewca/wca-regulations/issues/457. - if(wcifEvent == otherWcifEvent) { + let otherEvent = events.byId[otherWcifEvent.id]; + let canChangeTimeLimit = otherEvent.can_change_time_limit; + if(!canChangeTimeLimit || wcifEvent == otherWcifEvent) { return; } otherWcifRounds = otherWcifRounds.concat(otherWcifEvent.rounds.filter(r => r != wcifRound)); @@ -228,8 +288,8 @@ let RoundAttributeComponents = { } else { let otherSelectedRoundIds = timeLimit.cumulativeRoundIds.filter(roundId => roundId != wcifRound.id); description = (<span> - This round has a cross round cumulative time limit, see - guideline <a href="https://www.worldcubeassociation.org/regulations/guidelines.html#A1a2++" target="_blank">A1a2++</a>. + This round has a cross round cumulative time limit (see + guideline <a href="https://www.worldcubeassociation.org/regulations/guidelines.html#A1a2++" target="_blank">A1a2++</a>). This means that competitors have {centisecondsToString(timeLimit.centiseconds)} total for all of their solves in this round ({wcifRound.id}) {" "}<strong>shared with</strong>: @@ -270,19 +330,32 @@ let RoundAttributeComponents = { {timeLimit.cumulativeRoundIds.length >= 1 && ( <div className="row"> <div className="col-sm-offset-2 col-sm-10"> - {otherWcifRounds.map(wcifRound => { - let roundId = wcifRound.id; - return ( - <label key={roundId}> - <input type="checkbox" - value={roundId} - checked={timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0} - ref={c => roundCheckboxes.push(c) } - onChange={onChangeAggregator} /> - {roundIdToString(roundId)} - </label> - ); - })} + <ul className="list-unstyled"> + {otherWcifRounds.map(wcifRound => { + let roundId = wcifRound.id; + let eventId = roundId.split("-")[0]; + let event = events.byId[eventId]; + let checked = timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0; + let eventAlreadySelected = timeLimit.cumulativeRoundIds.find(roundId => roundId.split("-")[0] == eventId); + let disabled = !checked && eventAlreadySelected; + let disabledReason = disabled && `Cannot select this round because you've already selected a round with event ${event.name}`; + return ( + <li key={roundId}> + <div className="checkbox"> + <label title={disabledReason}> + <input type="checkbox" + value={roundId} + checked={checked} + disabled={disabled} + ref={c => roundCheckboxes.push(c) } + onChange={onChangeAggregator} /> + {roundIdToString(roundId)} + </label> + </div> + </li> + ); + })} + </ul> </div> </div> )} From 330acfb650f531c4a707c2b290ea667dcf6edda7 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 31 Aug 2017 17:20:22 -0700 Subject: [PATCH 19/42] Refactor modals code a bit, and create a AttemptResultInput component. The AttemptResultInput component still needs to be implemented, but I think it provides the right abstraction. --- .../app/javascript/edit-events/modals.jsx | 547 ------------------ .../modals/AdvancementCondition.jsx | 96 +++ .../edit-events/modals/AttemptResultInput.jsx | 51 ++ .../javascript/edit-events/modals/Cutoff.jsx | 79 +++ .../edit-events/modals/TimeLimit.jsx | 184 ++++++ .../javascript/edit-events/modals/index.jsx | 177 ++++++ .../javascript/edit-events/modals/utils.js | 36 ++ 7 files changed, 623 insertions(+), 547 deletions(-) delete mode 100644 WcaOnRails/app/javascript/edit-events/modals.jsx create mode 100644 WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx create mode 100644 WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx create mode 100644 WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx create mode 100644 WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx create mode 100644 WcaOnRails/app/javascript/edit-events/modals/index.jsx create mode 100644 WcaOnRails/app/javascript/edit-events/modals/utils.js diff --git a/WcaOnRails/app/javascript/edit-events/modals.jsx b/WcaOnRails/app/javascript/edit-events/modals.jsx deleted file mode 100644 index 790d98d18d..0000000000 --- a/WcaOnRails/app/javascript/edit-events/modals.jsx +++ /dev/null @@ -1,547 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom' -import Modal from 'react-bootstrap/lib/Modal' -import Radio from 'react-bootstrap/lib/Radio' -import Button from 'react-bootstrap/lib/Button' -import Checkbox from 'react-bootstrap/lib/Checkbox' - -import events from 'wca/events.js.erb' -import formats from 'wca/formats.js.erb' -import { rootRender } from 'edit-events' - -function roundIdToString(roundId) { - let [ eventId, roundNumber ] = roundId.split("-"); - roundNumber = parseInt(roundNumber); - let event = events.byId[eventId]; - return `${event.name}, Round ${roundNumber}`; -} - -function centisecondsToString(centiseconds) { - const seconds = centiseconds / 100; - const minutes = seconds / 60; - const hours = minutes / 60; - - // TODO <<< >>> - if(hours >= 1) { - return `${hours.toFixed(2)} hours`; - } else if(minutes >= 1) { - return `${minutes.toFixed(2)} minutes`; - } else { - return `${seconds.toFixed(2)} seconds`; - } -} - -function attemptResultToString(attemptResult, eventId) { - let event = events.byId[eventId]; - if(event.timed_event) { - return centisecondsToString(attemptResult); - } else if(event.fewest_moves) { - return `${attemptResult} moves`; - } else if(event.multiple_blindfolded) { - return `${attemptResult} points`; // TODO <<<>>> - } else { - throw new Error(`Unrecognized event type: ${eventId}`); - } -} - -class ButtonActivatedModal extends React.Component { - constructor() { - super(); - this.state = { showModal: false }; - } - - open = () => { - this.setState({ showModal: true }); - } - - close = () => { - this.props.reset(); - this.setState({ showModal: false }); - } - - render() { - return ( - <button type="button" className="btn btn-default btn-xs" - onClick={this.open}> - {this.props.buttonValue} - <Modal show={this.state.showModal} onHide={this.close} backdrop="static"> - <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> - {this.props.children} - <Modal.Footer> - <Button onClick={this.close} className="pull-left">Close</Button> - <Button onClick={this.props.reset} bsStyle="danger" className="pull-left">Reset</Button> - <Button type="submit" bsStyle="primary">Save</Button> - </Modal.Footer> - </form> - </Modal> - </button> - ); - } -} - -class RadioGroup extends React.Component { - get value() { - let formGroupDom = ReactDOM.findDOMNode(this.formGroup); - return formGroupDom.querySelectorAll('input:checked')[0].value - } - - render() { - return ( - <div ref={c => this.formGroup = c}> - {this.props.children.map(child => { - return React.cloneElement(child, { - name: this.props.name, - key: child.props.value, - checked: this.props.value == child.props.value, - onChange: this.props.onChange, - }); - })} - </div> - ); - } -} - -function findRoundsSharingTimeLimitWithRound(wcifEvents, wcifRound) { - let roundsSharingTimeLimit = []; - wcifEvents.forEach(otherWcifEvent => { - otherWcifEvent.rounds.forEach(otherWcifRound => { - if(otherWcifRound == wcifRound || !otherWcifRound.timeLimit) { - return; - } - - if(otherWcifRound.timeLimit.cumulativeRoundIds.indexOf(wcifRound.id) >= 0) { - roundsSharingTimeLimit.push(otherWcifRound); - } - }); - }); - return roundsSharingTimeLimit; -} - -function findRounds(wcifEvents, roundIds) { - let wcifRounds = []; - wcifEvents.forEach(wcifEvent => { - wcifEvent.rounds.forEach(wcifRound => { - if(roundIds.indexOf(wcifRound.id) >= 0) { - wcifRounds.push(wcifRound); - } - }); - }); - return wcifRounds; -} - -class EditRoundAttribute extends React.Component { - componentWillMount() { - this.reset(); - } - - componentWillReceiveProps() { - this.reset(); - } - - getWcifRound() { - let { wcifEvent, roundNumber } = this.props; - return wcifEvent.rounds[roundNumber - 1]; - } - - getSavedValue() { - return this.getWcifRound()[this.props.attribute]; - } - - onChange = (value) => { - this.setState({ value: value }); - } - - onSave = () => { - let wcifRound = this.getWcifRound(); - wcifRound[this.props.attribute] = this.state.value; - - // This is gross. timeLimit is special because of cross round cumulative time limits. - // If you set a time limit for 3x3x3 round 1 shared with 2x2x2 round 1, then we need - // to make sure the same timeLimit gets set for both of the rounds. - if(this.props.attribute == "timeLimit") { - let timeLimit = this.state.value; - - // First, remove this round from all other rounds that previously shared - // a time limit with this round. - findRoundsSharingTimeLimitWithRound(this.props.wcifEvents, wcifRound).forEach(otherWcifRound => { - let index = otherWcifRound.timeLimit.cumulativeRoundIds.indexOf(wcifRound.id); - if(index < 0) { - throw new Error(); - } - otherWcifRound.timeLimit.cumulativeRoundIds.splice(index, 1); - }); - - // Second, clobber the time limits for all rounds that this round now shares a time limit with. - if(timeLimit) { - findRounds(this.props.wcifEvents, timeLimit.cumulativeRoundIds).forEach(wcifRound => { - wcifRound.timeLimit = timeLimit; - }); - } - } - - this._modal.close(); - rootRender(); - } - - reset = () => { - this.setState({ value: this.getSavedValue() }); - } - - render() { - let { wcifEvents, wcifEvent, roundNumber } = this.props; - let wcifRound = this.getWcifRound(); - let Show = RoundAttributeComponents[this.props.attribute].Show; - let Input = RoundAttributeComponents[this.props.attribute].Input; - let Title = RoundAttributeComponents[this.props.attribute].Title; - - return ( - <ButtonActivatedModal - buttonValue={<Show value={this.getSavedValue()} wcifEvent={wcifEvent} />} - formClass="form-horizontal" - onSave={this.onSave} - reset={this.reset} - ref={c => this._modal = c} - > - <Modal.Header closeButton> - <Modal.Title><Title wcifRound={wcifRound} /></Modal.Title> - </Modal.Header> - <Modal.Body> - <Input value={this.state.value} wcifEvents={wcifEvents} wcifEvent={wcifEvent} roundNumber={roundNumber} onChange={this.onChange} autoFocus /> - </Modal.Body> - </ButtonActivatedModal> - ); - } -} - -let RoundAttributeComponents = { - timeLimit: { - Title({ wcifRound }) { - return <span>Time limit for {roundIdToString(wcifRound.id)}</span>; - }, - Show({ value: timeLimit }) { - let timeStr = centisecondsToString(timeLimit.centiseconds); - let str; - switch(timeLimit.cumulativeRoundIds.length) { - case 0: - str = timeStr; - break; - case 1: - str = timeStr + " cumulative"; - break; - default: - str = timeStr + ` total for ${timeLimit.cumulativeRoundIds.join(", ")}`; - break; - } - return <span>{str}</span>; - }, - Input: function({ value: timeLimit, autoFocus, wcifEvents, wcifEvent, roundNumber, onChange }) { - let event = events.byId[wcifEvent.id]; - let wcifRound = wcifEvent.rounds[roundNumber - 1]; - let format = formats.byId[wcifRound.format]; - - let otherWcifRounds = []; - wcifEvents.forEach(otherWcifEvent => { - // Cross round cumulative time limits may not include other rounds of - // the same event. - // See https://github.com/thewca/wca-regulations/issues/457. - let otherEvent = events.byId[otherWcifEvent.id]; - let canChangeTimeLimit = otherEvent.can_change_time_limit; - if(!canChangeTimeLimit || wcifEvent == otherWcifEvent) { - return; - } - otherWcifRounds = otherWcifRounds.concat(otherWcifEvent.rounds.filter(r => r != wcifRound)); - }); - - let centisInput, cumulativeInput, cumulativeRadio; - let roundCheckboxes = []; - let onChangeAggregator = () => { - let cumulativeRoundIds; - switch(cumulativeRadio.value) { - case "per-solve": - cumulativeRoundIds = []; - break; - case "cumulative": - cumulativeRoundIds = [wcifRound.id]; - cumulativeRoundIds = cumulativeRoundIds.concat(roundCheckboxes.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value)); - break; - default: - throw new Error(`Unrecognized value ${cumulativeRadio.value}`); - break; - } - - let newTimeLimit = { - centiseconds: parseInt(centisInput.value), - cumulativeRoundIds, - }; - onChange(newTimeLimit); - }; - - let description = null; - if(timeLimit.cumulativeRoundIds.length === 0) { - description = `Competitors have ${centisecondsToString(timeLimit.centiseconds)} for each of their solves.`; - } else if(timeLimit.cumulativeRoundIds.length === 1) { - description = (<span> - Competitors have {centisecondsToString(timeLimit.centiseconds)} total for all - of their solves in this round. This is called a cumulative time limit, defined in - regulation <a href="https://www.worldcubeassociation.org/regulations/#A1a2" target="_blank">A1a2</a>. - </span>); - } else { - let otherSelectedRoundIds = timeLimit.cumulativeRoundIds.filter(roundId => roundId != wcifRound.id); - description = (<span> - This round has a cross round cumulative time limit (see - guideline <a href="https://www.worldcubeassociation.org/regulations/guidelines.html#A1a2++" target="_blank">A1a2++</a>). - This means that competitors have {centisecondsToString(timeLimit.centiseconds)} total for all - of their solves in this round ({wcifRound.id}) - {" "}<strong>shared with</strong>: - <ul> - {otherSelectedRoundIds.map(roundId => <li key={roundId}>{roundIdToString(roundId)}</li>)} - </ul> - </span>); - } - - return ( - <div> - <div className="form-group"> - <label htmlFor="time-limit-input" className="col-sm-2 control-label">Time</label> - <div className="col-sm-10"> - <input type="number" - id="time-limit-input" - className="form-control" - autoFocus={autoFocus} - ref={c => centisInput = c} - value={timeLimit.centiseconds} - onChange={onChangeAggregator} /> - </div> - </div> - - <div className="form-group"> - <div className="col-sm-offset-2 col-sm-10"> - <RadioGroup value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} - name="cumulative-radio" - onChange={onChangeAggregator} - ref={c => cumulativeRadio = c} - > - <Radio value="per-solve" inline>Per Solve</Radio> - <Radio value="cumulative" inline>Cumulative</Radio> - </RadioGroup> - </div> - </div> - - {timeLimit.cumulativeRoundIds.length >= 1 && ( - <div className="row"> - <div className="col-sm-offset-2 col-sm-10"> - <ul className="list-unstyled"> - {otherWcifRounds.map(wcifRound => { - let roundId = wcifRound.id; - let eventId = roundId.split("-")[0]; - let event = events.byId[eventId]; - let checked = timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0; - let eventAlreadySelected = timeLimit.cumulativeRoundIds.find(roundId => roundId.split("-")[0] == eventId); - let disabled = !checked && eventAlreadySelected; - let disabledReason = disabled && `Cannot select this round because you've already selected a round with event ${event.name}`; - return ( - <li key={roundId}> - <div className="checkbox"> - <label title={disabledReason}> - <input type="checkbox" - value={roundId} - checked={checked} - disabled={disabled} - ref={c => roundCheckboxes.push(c) } - onChange={onChangeAggregator} /> - {roundIdToString(roundId)} - </label> - </div> - </li> - ); - })} - </ul> - </div> - </div> - )} - - <div className="row"> - <span className="col-sm-offset-2 col-sm-10">{description}</span> - </div> - </div> - ); - }, - }, - cutoff: { - Title({ wcifRound }) { - return <span>Cutoff for {roundIdToString(wcifRound.id)}</span>; - }, - Show({ value: cutoff, wcifEvent }) { - let str; - if(cutoff) { - str = `better than or equal to ${attemptResultToString(cutoff.attemptResult, wcifEvent.id)} in ${cutoff.numberOfAttempts}`; - } else { - str = "-"; - } - return <span>{str}</span>; - }, - Input({ value: cutoff, onChange, autoFocus, wcifEvent, roundNumber }) { - let wcifRound = wcifEvent.rounds[roundNumber - 1]; - - let numberOfAttemptsInput, attemptResultInput; - let onChangeAggregator = () => { - let numberOfAttempts = parseInt(numberOfAttemptsInput.value); - let newCutoff; - if(numberOfAttempts > 0) { - newCutoff = { - numberOfAttempts, - attemptResult: attemptResultInput ? parseInt(attemptResultInput.value) : 0, - }; - } else { - newCutoff = null; - } - onChange(newCutoff); - }; - - return ( - <div> - <div className="form-group"> - <label htmlFor="cutoff-round-format-input" className="col-sm-3 control-label">Round format</label> - <div className="col-sm-9"> - <div className="input-group"> - <select value={cutoff ? cutoff.numberOfAttempts : 0} - autoFocus={autoFocus} - onChange={onChangeAggregator} - className="form-control" - id="cutoff-round-format-input" - ref={c => numberOfAttemptsInput = c} - > - <option value={0}>No cutoff</option> - <option disabled="disabled">────────</option> - <option value={1}>Best of 1</option> - <option value={2}>Best of 2</option> - <option value={3}>Best of 3</option> - </select> - <div className="input-group-addon"> - <strong>/ {formats.byId[wcifRound.format].name}</strong> - </div> - </div> - </div> - </div> - {cutoff && ( - <div className="form-group"> - <label htmlFor="cutoff-input" className="col-sm-3 control-label">Cutoff</label> - <div className="col-sm-9"> - <input type="number" - className="form-control" - id="cutoff-input" - value={cutoff.attemptResult} - onChange={onChangeAggregator} - ref={c => attemptResultInput = c} - /> - </div> - </div> - )} - </div> - ); - }, - }, - advancementCondition: { - Title({ wcifRound }) { - return <span>Requirement to advance past {roundIdToString(wcifRound.id)}</span>; - }, - Show({ value: advancementCondition }) { - function advanceReqToStr(advancementCondition) { - // TODO <<< >>> - return advancementCondition ? `${advancementCondition.type} ${advancementCondition.level}` : "-"; - } - let str = advanceReqToStr(advancementCondition); - return <span>{str}</span>; - }, - Input({ value: advancementCondition, onChange, autoFocus, roundNumber, wcifEvent }) { - let typeInput, rankingInput, percentInput, attemptResultInput; - let onChangeAggregator = () => { - let type = typeInput.value; - let newAdvancementCondition; - switch(typeInput.value) { - case "ranking": - newAdvancementCondition = { - type: "ranking", - level: rankingInput ? parseInt(rankingInput.value): 0, - }; - break; - case "percent": - newAdvancementCondition = { - type: "percent", - level: percentInput ? parseInt(percentInput.value) : 0, - }; - break; - case "attemptResult": - newAdvancementCondition = { - type: "attemptResult", - level: attemptResultInput ? parseInt(attemptResultInput.value) : 0, - }; - break; - default: - newAdvancementCondition = null; - break; - } - onChange(newAdvancementCondition); - }; - - let advancementInput = null; - let helpBlock = null; - let advancementType = advancementCondition ? advancementCondition.type : ""; - switch(advancementType) { - case "ranking": - advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} />; - helpBlock = `The top ${advancementCondition.level} competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; - break; - case "percent": - advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} />; - helpBlock = `The top ${advancementCondition.level}% of competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; - break; - case "attemptResult": - advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} />; - helpBlock = `Everyone in round ${roundNumber} with a result better than or equal to ${attemptResultToString(advancementCondition.level, wcifEvent.id)} will advance to round ${roundNumber + 1}.`; - break; - default: - advancementInput = null; - break; - } - - return ( - <div> - <div className="form-group"> - <div className="col-sm-12"> - <div className="input-group advancement-condition"> - <select value={advancementCondition ? advancementCondition.type : ""} - autoFocus={autoFocus} - onChange={onChangeAggregator} - className="form-control" - ref={c => typeInput = c} - > - <option value="">To be announced</option> - <option disabled="disabled">────────</option> - <option value="ranking">Ranking</option> - <option value="percent">Percent</option> - <option value="attemptResult">Result</option> - </select> - - {advancementInput} - </div> - </div> - </div> - {helpBlock} - </div> - ); - }, - }, -}; - -export function EditTimeLimitButton(props) { - return <EditRoundAttribute {...props} attribute="timeLimit" />; -}; - -export function EditCutoffButton(props) { - return <EditRoundAttribute {...props} attribute="cutoff" />; -}; - -export function EditAdvancementConditionButton(props) { - return <EditRoundAttribute {...props} attribute="advancementCondition" />; -}; diff --git a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx new file mode 100644 index 0000000000..fa8f61feb9 --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx @@ -0,0 +1,96 @@ +import React from 'react' + +import AttemptResultInput from './AttemptResultInput' +import { attemptResultToString, roundIdToString } from './utils' + +export default { + Title({ wcifRound }) { + return <span>Requirement to advance past {roundIdToString(wcifRound.id)}</span>; + }, + Show({ value: advancementCondition }) { + function advanceReqToStr(advancementCondition) { + // TODO <<< >>> + return advancementCondition ? `${advancementCondition.type} ${advancementCondition.level}` : "-"; + } + let str = advanceReqToStr(advancementCondition); + return <span>{str}</span>; + }, + Input({ value: advancementCondition, onChange, autoFocus, roundNumber, wcifEvent }) { + let typeInput, rankingInput, percentInput, attemptResultInput; + let onChangeAggregator = () => { + let type = typeInput.value; + let newAdvancementCondition; + switch(typeInput.value) { + case "ranking": + newAdvancementCondition = { + type: "ranking", + level: rankingInput ? parseInt(rankingInput.value): 0, + }; + break; + case "percent": + newAdvancementCondition = { + type: "percent", + level: percentInput ? parseInt(percentInput.value) : 0, + }; + break; + case "attemptResult": + newAdvancementCondition = { + type: "attemptResult", + level: attemptResultInput ? parseInt(attemptResultInput.value) : 0, + }; + break; + default: + newAdvancementCondition = null; + break; + } + onChange(newAdvancementCondition); + }; + + let advancementInput = null; + let helpBlock = null; + let advancementType = advancementCondition ? advancementCondition.type : ""; + switch(advancementType) { + case "ranking": + advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} />; + helpBlock = `The top ${advancementCondition.level} competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; + break; + case "percent": + advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} />; + helpBlock = `The top ${advancementCondition.level}% of competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; + break; + case "attemptResult": + advancementInput = <AttemptResultInput eventId={wcifEvent.id} value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} />; + helpBlock = `Everyone in round ${roundNumber} with a result better than or equal to ${attemptResultToString(advancementCondition.level, wcifEvent.id)} will advance to round ${roundNumber + 1}.`; + break; + default: + advancementInput = null; + break; + } + + return ( + <div> + <div className="form-group"> + <div className="col-sm-12"> + <div className="input-group advancement-condition"> + <select value={advancementCondition ? advancementCondition.type : ""} + autoFocus={autoFocus} + onChange={onChangeAggregator} + className="form-control" + ref={c => typeInput = c} + > + <option value="">To be announced</option> + <option disabled="disabled">────────</option> + <option value="ranking">Ranking</option> + <option value="percent">Percent</option> + <option value="attemptResult">Result</option> + </select> + + {advancementInput} + </div> + </div> + </div> + {helpBlock} + </div> + ); + }, +}; diff --git a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx new file mode 100644 index 0000000000..c2d48ccdac --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx @@ -0,0 +1,51 @@ +import React from 'react' + +import events from 'wca/events.js.erb' + +export default class extends React.Component { + constructor(props) { + super(props); + } + + onChange = () => { + this.props.onChange(); + } + + get value() { + return parseInt(this.centisInput.value); + } + + render() { + let { id, autoFocus } = this.props; + let event = events.byId[this.props.eventId]; + + if(event.timed_event) { + return ( + <div> + <input type="number" + id={id} + className="form-control" + autoFocus={autoFocus} + value={this.props.value} + ref={c => this.centisInput = c} + onChange={this.onChange} /> + (centiseconds) + </div> + ); + } else if(event.fewest_moves) { + return ( + <div> + fewest moves? urg + </div> + ); + } else if(event.multiple_blindfolded) { + return ( + <div> + multiblind? urg + </div> + ); + } else { + throw new Error(`Unrecognized event type: ${event.id}`); + } + } +} diff --git a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx new file mode 100644 index 0000000000..8cc4036cb7 --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx @@ -0,0 +1,79 @@ +import React from 'react' + +import formats from 'wca/formats.js.erb' +import AttemptResultInput from './AttemptResultInput' +import { attemptResultToString, roundIdToString } from './utils' + +export default { + Title({ wcifRound }) { + return <span>Cutoff for {roundIdToString(wcifRound.id)}</span>; + }, + Show({ value: cutoff, wcifEvent }) { + let str; + if(cutoff) { + str = `better than or equal to ${attemptResultToString(cutoff.attemptResult, wcifEvent.id)} in ${cutoff.numberOfAttempts}`; + } else { + str = "-"; + } + return <span>{str}</span>; + }, + Input({ value: cutoff, onChange, autoFocus, wcifEvent, roundNumber }) { + let wcifRound = wcifEvent.rounds[roundNumber - 1]; + + let numberOfAttemptsInput, attemptResultInput; + let onChangeAggregator = () => { + let numberOfAttempts = parseInt(numberOfAttemptsInput.value); + let newCutoff; + if(numberOfAttempts > 0) { + newCutoff = { + numberOfAttempts, + attemptResult: attemptResultInput ? parseInt(attemptResultInput.value) : 0, + }; + } else { + newCutoff = null; + } + onChange(newCutoff); + }; + + return ( + <div> + <div className="form-group"> + <label htmlFor="cutoff-round-format-input" className="col-sm-3 control-label">Round format</label> + <div className="col-sm-9"> + <div className="input-group"> + <select value={cutoff ? cutoff.numberOfAttempts : 0} + autoFocus={autoFocus} + onChange={onChangeAggregator} + className="form-control" + id="cutoff-round-format-input" + ref={c => numberOfAttemptsInput = c} + > + <option value={0}>No cutoff</option> + <option disabled="disabled">────────</option> + <option value={1}>Best of 1</option> + <option value={2}>Best of 2</option> + <option value={3}>Best of 3</option> + </select> + <div className="input-group-addon"> + <strong>/ {formats.byId[wcifRound.format].name}</strong> + </div> + </div> + </div> + </div> + {cutoff && ( + <div className="form-group"> + <label htmlFor="cutoff-input" className="col-sm-3 control-label">Cutoff</label> + <div className="col-sm-9"> + <AttemptResultInput eventId={wcifEvent.id} + id="cutoff-input" + value={cutoff.attemptResult} + onChange={onChangeAggregator} + ref={c => attemptResultInput = c} + /> + </div> + </div> + )} + </div> + ); + }, +}; diff --git a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx new file mode 100644 index 0000000000..94829cc248 --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx @@ -0,0 +1,184 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import Radio from 'react-bootstrap/lib/Radio' + +import events from 'wca/events.js.erb' +import formats from 'wca/formats.js.erb' +import AttemptResultInput from './AttemptResultInput' +import { centisecondsToString, roundIdToString } from './utils' + +class RadioGroup extends React.Component { + get value() { + let formGroupDom = ReactDOM.findDOMNode(this.formGroup); + return formGroupDom.querySelector('input:checked').value; + } + + render() { + return ( + <div ref={c => this.formGroup = c}> + {this.props.children.map(child => { + return React.cloneElement(child, { + name: this.props.name, + key: child.props.value, + checked: this.props.value == child.props.value, + onChange: this.props.onChange, + }); + })} + </div> + ); + } +} + +export default { + Title({ wcifRound }) { + return <span>Time limit for {roundIdToString(wcifRound.id)}</span>; + }, + Show({ value: timeLimit }) { + let timeStr = centisecondsToString(timeLimit.centiseconds); + let str; + switch(timeLimit.cumulativeRoundIds.length) { + case 0: + str = timeStr; + break; + case 1: + str = timeStr + " cumulative"; + break; + default: + str = timeStr + ` total for ${timeLimit.cumulativeRoundIds.join(", ")}`; + break; + } + return <span>{str}</span>; + }, + Input: function({ value: timeLimit, autoFocus, wcifEvents, wcifEvent, roundNumber, onChange }) { + let event = events.byId[wcifEvent.id]; + let wcifRound = wcifEvent.rounds[roundNumber - 1]; + let format = formats.byId[wcifRound.format]; + + let otherWcifRounds = []; + wcifEvents.forEach(otherWcifEvent => { + // Cross round cumulative time limits may not include other rounds of + // the same event. + // See https://github.com/thewca/wca-regulations/issues/457. + let otherEvent = events.byId[otherWcifEvent.id]; + let canChangeTimeLimit = otherEvent.can_change_time_limit; + if(!canChangeTimeLimit || wcifEvent == otherWcifEvent) { + return; + } + otherWcifRounds = otherWcifRounds.concat(otherWcifEvent.rounds.filter(r => r != wcifRound)); + }); + + let centisInput, cumulativeInput, cumulativeRadio; + let roundCheckboxes = []; + let onChangeAggregator = () => { + let cumulativeRoundIds; + switch(cumulativeRadio.value) { + case "per-solve": + cumulativeRoundIds = []; + break; + case "cumulative": + cumulativeRoundIds = [wcifRound.id]; + cumulativeRoundIds = cumulativeRoundIds.concat(roundCheckboxes.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value)); + break; + default: + throw new Error(`Unrecognized value ${cumulativeRadio.value}`); + break; + } + + let newTimeLimit = { + centiseconds: parseInt(centisInput.value), + cumulativeRoundIds, + }; + onChange(newTimeLimit); + }; + + let description = null; + if(timeLimit.cumulativeRoundIds.length === 0) { + description = `Competitors have ${centisecondsToString(timeLimit.centiseconds)} for each of their solves.`; + } else if(timeLimit.cumulativeRoundIds.length === 1) { + description = (<span> + Competitors have {centisecondsToString(timeLimit.centiseconds)} total for all + of their solves in this round. This is called a cumulative time limit, defined in + regulation <a href="https://www.worldcubeassociation.org/regulations/#A1a2" target="_blank">A1a2</a>. + </span>); + } else { + let otherSelectedRoundIds = timeLimit.cumulativeRoundIds.filter(roundId => roundId != wcifRound.id); + description = (<span> + This round has a cross round cumulative time limit (see + guideline <a href="https://www.worldcubeassociation.org/regulations/guidelines.html#A1a2++" target="_blank">A1a2++</a>). + This means that competitors have {centisecondsToString(timeLimit.centiseconds)} total for all + of their solves in this round ({wcifRound.id}) + {" "}<strong>shared with</strong>: + <ul> + {otherSelectedRoundIds.map(roundId => <li key={roundId}>{roundIdToString(roundId)}</li>)} + </ul> + </span>); + } + + return ( + <div> + <div className="form-group"> + <label htmlFor="time-limit-input" className="col-sm-2 control-label">Time</label> + <div className="col-sm-10"> + <AttemptResultInput eventId={event.id} + id="time-limit-input" + ref={c => centisInput = c} + autoFocus={autoFocus} + value={timeLimit.centiseconds} + onChange={onChangeAggregator} + /> + </div> + </div> + + <div className="form-group"> + <div className="col-sm-offset-2 col-sm-10"> + <RadioGroup value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} + name="cumulative-radio" + onChange={onChangeAggregator} + ref={c => cumulativeRadio = c} + > + <Radio value="per-solve" inline>Per Solve</Radio> + <Radio value="cumulative" inline>Cumulative</Radio> + </RadioGroup> + </div> + </div> + + {timeLimit.cumulativeRoundIds.length >= 1 && ( + <div className="row"> + <div className="col-sm-offset-2 col-sm-10"> + <ul className="list-unstyled"> + {otherWcifRounds.map(wcifRound => { + let roundId = wcifRound.id; + let eventId = roundId.split("-")[0]; + let event = events.byId[eventId]; + let checked = timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0; + let eventAlreadySelected = timeLimit.cumulativeRoundIds.find(roundId => roundId.split("-")[0] == eventId); + let disabled = !checked && eventAlreadySelected; + let disabledReason = disabled && `Cannot select this round because you've already selected a round with ${event.name}`; + return ( + <li key={roundId}> + <div className="checkbox"> + <label title={disabledReason}> + <input type="checkbox" + value={roundId} + checked={checked} + disabled={disabled} + ref={c => roundCheckboxes.push(c) } + onChange={onChangeAggregator} /> + {roundIdToString(roundId)} + </label> + </div> + </li> + ); + })} + </ul> + </div> + </div> + )} + + <div className="row"> + <span className="col-sm-offset-2 col-sm-10">{description}</span> + </div> + </div> + ); + }, +}; diff --git a/WcaOnRails/app/javascript/edit-events/modals/index.jsx b/WcaOnRails/app/javascript/edit-events/modals/index.jsx new file mode 100644 index 0000000000..fb4559b9bf --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/modals/index.jsx @@ -0,0 +1,177 @@ +import React from 'react' +import Modal from 'react-bootstrap/lib/Modal' +import Button from 'react-bootstrap/lib/Button' +import Checkbox from 'react-bootstrap/lib/Checkbox' + +import events from 'wca/events.js.erb' +import formats from 'wca/formats.js.erb' +import { rootRender } from 'edit-events' + +import CutoffComponents from './Cutoff' +import TimeLimitComponents from './TimeLimit' +import AdvancementConditionComponents from './AdvancementCondition' + +let RoundAttributeComponents = { + timeLimit: TimeLimitComponents, + cutoff: CutoffComponents, + advancementCondition: AdvancementConditionComponents, +}; + +function findRoundsSharingTimeLimitWithRound(wcifEvents, wcifRound) { + let roundsSharingTimeLimit = []; + wcifEvents.forEach(otherWcifEvent => { + otherWcifEvent.rounds.forEach(otherWcifRound => { + if(otherWcifRound == wcifRound || !otherWcifRound.timeLimit) { + return; + } + + if(otherWcifRound.timeLimit.cumulativeRoundIds.indexOf(wcifRound.id) >= 0) { + roundsSharingTimeLimit.push(otherWcifRound); + } + }); + }); + return roundsSharingTimeLimit; +} + +function findRounds(wcifEvents, roundIds) { + let wcifRounds = []; + wcifEvents.forEach(wcifEvent => { + wcifEvent.rounds.forEach(wcifRound => { + if(roundIds.indexOf(wcifRound.id) >= 0) { + wcifRounds.push(wcifRound); + } + }); + }); + return wcifRounds; +} + +class ButtonActivatedModal extends React.Component { + constructor() { + super(); + this.state = { showModal: false }; + } + + open = () => { + this.setState({ showModal: true }); + } + + close = () => { + this.props.reset(); + this.setState({ showModal: false }); + } + + render() { + return ( + <button type="button" className="btn btn-default btn-xs" + onClick={this.open}> + {this.props.buttonValue} + <Modal show={this.state.showModal} onHide={this.close} backdrop="static"> + <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> + {this.props.children} + <Modal.Footer> + <Button onClick={this.close} className="pull-left">Close</Button> + <Button onClick={this.props.reset} bsStyle="danger" className="pull-left">Reset</Button> + <Button type="submit" bsStyle="primary">Save</Button> + </Modal.Footer> + </form> + </Modal> + </button> + ); + } +} + +class EditRoundAttribute extends React.Component { + componentWillMount() { + this.reset(); + } + + componentWillReceiveProps() { + this.reset(); + } + + getWcifRound() { + let { wcifEvent, roundNumber } = this.props; + return wcifEvent.rounds[roundNumber - 1]; + } + + getSavedValue() { + return this.getWcifRound()[this.props.attribute]; + } + + onChange = (value) => { + this.setState({ value: value }); + } + + onSave = () => { + let wcifRound = this.getWcifRound(); + wcifRound[this.props.attribute] = this.state.value; + + // This is gross. timeLimit is special because of cross round cumulative time limits. + // If you set a time limit for 3x3x3 round 1 shared with 2x2x2 round 1, then we need + // to make sure the same timeLimit gets set for both of the rounds. + if(this.props.attribute == "timeLimit") { + let timeLimit = this.state.value; + + // First, remove this round from all other rounds that previously shared + // a time limit with this round. + findRoundsSharingTimeLimitWithRound(this.props.wcifEvents, wcifRound).forEach(otherWcifRound => { + let index = otherWcifRound.timeLimit.cumulativeRoundIds.indexOf(wcifRound.id); + if(index < 0) { + throw new Error(); + } + otherWcifRound.timeLimit.cumulativeRoundIds.splice(index, 1); + }); + + // Second, clobber the time limits for all rounds that this round now shares a time limit with. + if(timeLimit) { + findRounds(this.props.wcifEvents, timeLimit.cumulativeRoundIds).forEach(wcifRound => { + wcifRound.timeLimit = timeLimit; + }); + } + } + + this._modal.close(); + rootRender(); + } + + reset = () => { + this.setState({ value: this.getSavedValue() }); + } + + render() { + let { wcifEvents, wcifEvent, roundNumber } = this.props; + let wcifRound = this.getWcifRound(); + let Show = RoundAttributeComponents[this.props.attribute].Show; + let Input = RoundAttributeComponents[this.props.attribute].Input; + let Title = RoundAttributeComponents[this.props.attribute].Title; + + return ( + <ButtonActivatedModal + buttonValue={<Show value={this.getSavedValue()} wcifEvent={wcifEvent} />} + formClass="form-horizontal" + onSave={this.onSave} + reset={this.reset} + ref={c => this._modal = c} + > + <Modal.Header closeButton> + <Modal.Title><Title wcifRound={wcifRound} /></Modal.Title> + </Modal.Header> + <Modal.Body> + <Input value={this.state.value} wcifEvents={wcifEvents} wcifEvent={wcifEvent} roundNumber={roundNumber} onChange={this.onChange} autoFocus /> + </Modal.Body> + </ButtonActivatedModal> + ); + } +} + +export function EditTimeLimitButton(props) { + return <EditRoundAttribute {...props} attribute="timeLimit" />; +}; + +export function EditCutoffButton(props) { + return <EditRoundAttribute {...props} attribute="cutoff" />; +}; + +export function EditAdvancementConditionButton(props) { + return <EditRoundAttribute {...props} attribute="advancementCondition" />; +}; diff --git a/WcaOnRails/app/javascript/edit-events/modals/utils.js b/WcaOnRails/app/javascript/edit-events/modals/utils.js new file mode 100644 index 0000000000..3192cf4c4d --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/modals/utils.js @@ -0,0 +1,36 @@ +import events from 'wca/events.js.erb' + +export function attemptResultToString(attemptResult, eventId) { + let event = events.byId[eventId]; + if(event.timed_event) { + return centisecondsToString(attemptResult); + } else if(event.fewest_moves) { + return `${attemptResult} moves`; + } else if(event.multiple_blindfolded) { + return `${attemptResult} points`; // TODO <<<>>> + } else { + throw new Error(`Unrecognized event type: ${eventId}`); + } +} + +export function centisecondsToString(centiseconds) { + const seconds = centiseconds / 100; + const minutes = seconds / 60; + const hours = minutes / 60; + + // TODO <<< >>> + if(hours >= 1) { + return `${hours.toFixed(2)} hours`; + } else if(minutes >= 1) { + return `${minutes.toFixed(2)} minutes`; + } else { + return `${seconds.toFixed(2)} seconds`; + } +} + +export function roundIdToString(roundId) { + let [ eventId, roundNumber ] = roundId.split("-"); + roundNumber = parseInt(roundNumber); + let event = events.byId[eventId]; + return `${event.name}, Round ${roundNumber}`; +} From 280e7bcb645220435fa3e46a670d1a39849d651b Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 31 Aug 2017 17:26:30 -0700 Subject: [PATCH 20/42] Implement advanceReqToStr. --- .../modals/AdvancementCondition.jsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx index fa8f61feb9..d6bcfb0da7 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx @@ -7,10 +7,26 @@ export default { Title({ wcifRound }) { return <span>Requirement to advance past {roundIdToString(wcifRound.id)}</span>; }, - Show({ value: advancementCondition }) { + Show({ value: advancementCondition, wcifEvent }) { function advanceReqToStr(advancementCondition) { - // TODO <<< >>> - return advancementCondition ? `${advancementCondition.type} ${advancementCondition.level}` : "-"; + if(!advancementCondition) { + return "-"; + } + + switch(advancementCondition.type) { + case "ranking": + return `Top ${advancementCondition.level}`; + break; + case "percent": + return `Top ${advancementCondition.level}%`; + break; + case "attemptResult": + return attemptResultToString(advancementCondition.level, wcifEvent.id); + break; + default: + throw new Error(`Unrecognized advancementCondition type: ${advancementCondition.type}`); + break; + } } let str = advanceReqToStr(advancementCondition); return <span>{str}</span>; From cec04e43a180f134e7e90c9ae13d9cfaedf9b3de Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 31 Aug 2017 18:10:23 -0700 Subject: [PATCH 21/42] Add support for mbld points =) --- .../edit-events/modals/AttemptResultInput.jsx | 31 +++++++++-- .../javascript/edit-events/modals/utils.js | 51 ++++++++++++++++++- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx index c2d48ccdac..f414e4a6d6 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx @@ -1,6 +1,7 @@ import React from 'react' import events from 'wca/events.js.erb' +import { mbPointsToAttemptResult, attemptResultToMbPoints } from './utils' export default class extends React.Component { constructor(props) { @@ -12,7 +13,17 @@ export default class extends React.Component { } get value() { - return parseInt(this.centisInput.value); + let event = events.byId[this.props.eventId]; + + if(event.timed_event) { + return parseInt(this.centisInput.value); + } else if(event.fewest_moves) { + return parseInt(this.movesInput.value); + } else if(event.multiple_blindfolded) { + return mbPointsToAttemptResult(parseInt(this.mbldPointsInput.value)); + } else { + throw new Error(`Unrecognized event type: ${event.id}`); + } } render() { @@ -35,13 +46,27 @@ export default class extends React.Component { } else if(event.fewest_moves) { return ( <div> - fewest moves? urg + <input type="number" + id={id} + className="form-control" + autoFocus={autoFocus} + value={this.props.value} + ref={c => this.movesInput = c} + onChange={this.onChange} /> + (moves) </div> ); } else if(event.multiple_blindfolded) { return ( <div> - multiblind? urg + <input type="number" + id={id} + className="form-control" + autoFocus={autoFocus} + value={attemptResultToMbPoints(this.props.value)} + ref={c => this.mbldPointsInput = c} + onChange={this.onChange} /> + (mbld points) </div> ); } else { diff --git a/WcaOnRails/app/javascript/edit-events/modals/utils.js b/WcaOnRails/app/javascript/edit-events/modals/utils.js index 3192cf4c4d..7b84acc766 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/utils.js +++ b/WcaOnRails/app/javascript/edit-events/modals/utils.js @@ -1,5 +1,53 @@ import events from 'wca/events.js.erb' +function parseMbValue(mbValue) { + let old = Math.floor(mbValue / 1000000000) !== 0; + let timeSeconds, attempted, solved; + if(old) { + timeSeconds = mbValue % 100000; + mbValue = Math.floor(mbValue / 100000); + attempted = mbValue % 100; + mbValue = Math.floor(mbValue / 100); + solved = 99 - mbValue % 100; + } else { + let missed = mbValue % 100; + mbValue = Math.floor(mbValue / 100); + timeSeconds = mbValue % 100000; + mbValue = Math.floor(mbValue / 100000); + let difference = 99 - (mbValue % 100); + + solved = difference + missed; + attempted = solved + missed; + } + + let timeCentiseconds = timeSeconds == 99999 ? null : timeSeconds * 100; + return { solved, attempted, timeCentiseconds }; +} + +function parsedMbToAttemptResult(parsedMb) { + let { solved, attempted, timeCentiseconds } = parsedMb; + let missed = attempted - solved; + + let mm = missed; + let dd = 99 - (solved - missed); + let ttttt = Math.floor(timeCentiseconds / 100); + return (dd * 1e7 + ttttt * 1e2 + mm); +} + +// See https://www.worldcubeassociation.org/regulations/#9f12c +export function attemptResultToMbPoints(mbValue) { + let { solved, attempted } = parseMbValue(mbValue); + let missed = attempted - solved; + return solved - missed; +} + +export function mbPointsToAttemptResult(mbPoints) { + let solved = mbPoints; + let attempted = mbPoints; + let timeCentiseconds = 99999*100; + return parsedMbToAttemptResult({ solved, attempted, timeCentiseconds }); +} + export function attemptResultToString(attemptResult, eventId) { let event = events.byId[eventId]; if(event.timed_event) { @@ -7,7 +55,7 @@ export function attemptResultToString(attemptResult, eventId) { } else if(event.fewest_moves) { return `${attemptResult} moves`; } else if(event.multiple_blindfolded) { - return `${attemptResult} points`; // TODO <<<>>> + return `${attemptResultToMbPoints(attemptResult)} points`; } else { throw new Error(`Unrecognized event type: ${eventId}`); } @@ -18,7 +66,6 @@ export function centisecondsToString(centiseconds) { const minutes = seconds / 60; const hours = minutes / 60; - // TODO <<< >>> if(hours >= 1) { return `${hours.toFixed(2)} hours`; } else if(minutes >= 1) { From 798a6f7dd909d8fe4a58b0724e73aa10fa8c34a5 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 1 Sep 2017 11:46:08 -0700 Subject: [PATCH 22/42] Some polish: short formatting of times for our tiny buttons. Also formatting our selects consistently. --- .../app/assets/stylesheets/edit_events.scss | 3 ++ WcaOnRails/app/assets/stylesheets/wca.scss | 9 +++++ .../app/javascript/edit-events/EditEvents.jsx | 10 +++-- .../modals/AdvancementCondition.jsx | 2 +- .../edit-events/modals/AttemptResultInput.jsx | 6 +-- .../javascript/edit-events/modals/Cutoff.jsx | 5 ++- .../edit-events/modals/TimeLimit.jsx | 6 +-- .../javascript/edit-events/modals/utils.js | 39 +++++++++++++------ 8 files changed, 56 insertions(+), 24 deletions(-) diff --git a/WcaOnRails/app/assets/stylesheets/edit_events.scss b/WcaOnRails/app/assets/stylesheets/edit_events.scss index f912dcc0d4..09c584f611 100644 --- a/WcaOnRails/app/assets/stylesheets/edit_events.scss +++ b/WcaOnRails/app/assets/stylesheets/edit_events.scss @@ -6,6 +6,8 @@ .panel-title { white-space: nowrap; overflow: hidden; + display: flex; + align-items: center; $cubing-icon-size: 55px; .img-thumbnail.cubing-icon { @@ -18,6 +20,7 @@ .title { padding-left: $cubing-icon-size; + padding-right: 5px; } } } diff --git a/WcaOnRails/app/assets/stylesheets/wca.scss b/WcaOnRails/app/assets/stylesheets/wca.scss index ac5df4c945..d07725b31a 100644 --- a/WcaOnRails/app/assets/stylesheets/wca.scss +++ b/WcaOnRails/app/assets/stylesheets/wca.scss @@ -415,3 +415,12 @@ button.saving { animation: ld 1s ease-in-out infinite; } } + +// Modified from https://stackoverflow.com/a/22920590 +.input-xs { + height: 22px; + padding: 2px 0; + font-size: 12px; + line-height: 1.5; /* If Placeholder of the input is moved up, rem/modify this. */ + border-radius: 3px; +} diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index 3fd4215d37..90b7efcb55 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -108,12 +108,16 @@ function RoundsTable({ wcifEvents, wcifEvent }) { rootRender(); }; + let abbreviate = str => { + return str.split(" ").map(word => word[0]).join(""); + }; + return ( <tr key={roundNumber}> <td>{roundNumber}</td> <td> - <select value={wcifRound.format} onChange={roundFormatChanged}> - {event.formats().map(format => <option key={format.id} value={format.id}>{format.name}</option>)} + <select className="form-control input-xs" value={wcifRound.format} onChange={roundFormatChanged}> + {event.formats().map(format => <option key={format.id} value={format.id}>{abbreviate(format.name)}</option>)} </select> </td> @@ -168,7 +172,7 @@ const EventPanel = ({ wcifEvents, wcifEvent }) => { <span className={classNames("img-thumbnail", "cubing-icon", `event-${event.id}`)}></span> <span className="title">{event.name}</span> {" "} - <select value={wcifEvent.rounds.length} onChange={roundCountChanged}> + <select className="form-control input-xs" value={wcifEvent.rounds.length} onChange={roundCountChanged}> <option value={0}>Not being held</option> <option disabled="disabled">────────</option> <option value={1}>1 round</option> diff --git a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx index d6bcfb0da7..a27c1f8930 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx @@ -21,7 +21,7 @@ export default { return `Top ${advancementCondition.level}%`; break; case "attemptResult": - return attemptResultToString(advancementCondition.level, wcifEvent.id); + return attemptResultToString(advancementCondition.level, wcifEvent.id, { short: true }); break; default: throw new Error(`Unrecognized advancementCondition type: ${advancementCondition.type}`); diff --git a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx index f414e4a6d6..53d8e01aa1 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx @@ -4,10 +4,6 @@ import events from 'wca/events.js.erb' import { mbPointsToAttemptResult, attemptResultToMbPoints } from './utils' export default class extends React.Component { - constructor(props) { - super(props); - } - onChange = () => { this.props.onChange(); } @@ -33,7 +29,7 @@ export default class extends React.Component { if(event.timed_event) { return ( <div> - <input type="number" + <input type="text" id={id} className="form-control" autoFocus={autoFocus} diff --git a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx index 8cc4036cb7..7f9b346021 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx @@ -1,5 +1,6 @@ import React from 'react' +import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' import AttemptResultInput from './AttemptResultInput' import { attemptResultToString, roundIdToString } from './utils' @@ -11,7 +12,9 @@ export default { Show({ value: cutoff, wcifEvent }) { let str; if(cutoff) { - str = `better than or equal to ${attemptResultToString(cutoff.attemptResult, wcifEvent.id)} in ${cutoff.numberOfAttempts}`; + let event = events.byId[wcifEvent.id]; + let comparisonString = event.multiple_blindfolded ? "≥" : "≤"; + str = `Best of ${cutoff.numberOfAttempts} ${comparisonString} ${attemptResultToString(cutoff.attemptResult, wcifEvent.id, { short: true })}`; } else { str = "-"; } diff --git a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx index 94829cc248..ad2d996927 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx @@ -34,7 +34,7 @@ export default { return <span>Time limit for {roundIdToString(wcifRound.id)}</span>; }, Show({ value: timeLimit }) { - let timeStr = centisecondsToString(timeLimit.centiseconds); + let timeStr = centisecondsToString(timeLimit.centiseconds, { short: true }); let str; switch(timeLimit.cumulativeRoundIds.length) { case 0: @@ -97,8 +97,8 @@ export default { } else if(timeLimit.cumulativeRoundIds.length === 1) { description = (<span> Competitors have {centisecondsToString(timeLimit.centiseconds)} total for all - of their solves in this round. This is called a cumulative time limit, defined in - regulation <a href="https://www.worldcubeassociation.org/regulations/#A1a2" target="_blank">A1a2</a>. + of their solves in this round. This is called a cumulative time limit (see + regulation <a href="https://www.worldcubeassociation.org/regulations/#A1a2" target="_blank">A1a2</a>). </span>); } else { let otherSelectedRoundIds = timeLimit.cumulativeRoundIds.filter(roundId => roundId != wcifRound.id); diff --git a/WcaOnRails/app/javascript/edit-events/modals/utils.js b/WcaOnRails/app/javascript/edit-events/modals/utils.js index 7b84acc766..0c8d393fcf 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/utils.js +++ b/WcaOnRails/app/javascript/edit-events/modals/utils.js @@ -48,10 +48,10 @@ export function mbPointsToAttemptResult(mbPoints) { return parsedMbToAttemptResult({ solved, attempted, timeCentiseconds }); } -export function attemptResultToString(attemptResult, eventId) { +export function attemptResultToString(attemptResult, eventId, { short } = {}) { let event = events.byId[eventId]; if(event.timed_event) { - return centisecondsToString(attemptResult); + return centisecondsToString(attemptResult, { short }); } else if(event.fewest_moves) { return `${attemptResult} moves`; } else if(event.multiple_blindfolded) { @@ -61,18 +61,35 @@ export function attemptResultToString(attemptResult, eventId) { } } -export function centisecondsToString(centiseconds) { - const seconds = centiseconds / 100; - const minutes = seconds / 60; - const hours = minutes / 60; +let pluralize = function(count, word, { fixed, abbreviate } = {}) { + let countStr = (fixed && count % 1 > 0) ? count.toFixed(fixed) : count; + let countDesc = abbreviate ? word[0] : " " + (count == 1 ? word : word + "s"); + return countStr + countDesc; +} +const SECOND_IN_CS = 100; +const MINUTE_IN_CS = 60*SECOND_IN_CS; +const HOUR_IN_CS = 60*MINUTE_IN_CS; +export function centisecondsToString(centiseconds, { short } = {}) { + let str = ""; + const hours = centiseconds / HOUR_IN_CS; + centiseconds %= HOUR_IN_CS; if(hours >= 1) { - return `${hours.toFixed(2)} hours`; - } else if(minutes >= 1) { - return `${minutes.toFixed(2)} minutes`; - } else { - return `${seconds.toFixed(2)} seconds`; + str += pluralize(Math.floor(hours), "hour", { abbreviate: short }) + " "; + } + + let minutes = centiseconds / MINUTE_IN_CS; + centiseconds %= MINUTE_IN_CS; + if(minutes >= 1) { + str += pluralize(Math.floor(minutes), "minute", { abbreviate: short }) + " "; } + + let seconds = centiseconds / SECOND_IN_CS; + if(seconds > 0) { + str += pluralize(seconds, "second", { fixed: 2, abbreviate: short }) + " "; + } + + return str.trim(); } export function roundIdToString(roundId) { From 6b7f1a7fde51e564456597e1f1ea4bf0387b28fb Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 1 Sep 2017 12:53:59 -0700 Subject: [PATCH 23/42] Add a silly simple centiseconds input. --- .../edit-events/modals/AttemptResultInput.jsx | 84 +++++++++++++++---- .../javascript/edit-events/modals/utils.js | 8 +- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx index 53d8e01aa1..21d7a346f9 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx @@ -1,7 +1,65 @@ import React from 'react' import events from 'wca/events.js.erb' -import { mbPointsToAttemptResult, attemptResultToMbPoints } from './utils' +import { + MINUTE_IN_CS, + SECOND_IN_CS, + mbPointsToAttemptResult, + attemptResultToMbPoints, +} from './utils' + +class CentisecondsInput extends React.Component { + get value() { + let minutes = parseInt(this.minutesInput.value) || 0; + let seconds = parseInt(this.secondsInput.value) || 0; + let centiseconds = parseInt(this.centisecondsInput.value) || 0; + return minutes*60*100 + seconds*100 + centiseconds; + } + + render() { + let { id, autoFocus, centiseconds, onChange } = this.props; + + let minutes = Math.floor(centiseconds / MINUTE_IN_CS); + centiseconds %= MINUTE_IN_CS; + + let seconds = Math.floor(centiseconds / SECOND_IN_CS); + centiseconds %= SECOND_IN_CS; + + return ( + <div> + <input type="number" + id={id} + className="form-control" + autoFocus={autoFocus} + value={minutes} + min={0} max={60} + ref={c => this.minutesInput = c} + onChange={onChange} /> + minutes + + <input type="number" + id={id} + className="form-control" + autoFocus={autoFocus} + value={seconds} + min={0} max={59} + ref={c => this.secondsInput = c} + onChange={onChange} /> + seconds + + <input type="number" + id={id} + className="form-control" + autoFocus={autoFocus} + value={centiseconds} + min={0} max={99} + ref={c => this.centisecondsInput = c} + onChange={onChange} /> + centiseconds + </div> + ); + } +} export default class extends React.Component { onChange = () => { @@ -12,7 +70,7 @@ export default class extends React.Component { let event = events.byId[this.props.eventId]; if(event.timed_event) { - return parseInt(this.centisInput.value); + return this.centisecondsInput.value; } else if(event.fewest_moves) { return parseInt(this.movesInput.value); } else if(event.multiple_blindfolded) { @@ -27,18 +85,12 @@ export default class extends React.Component { let event = events.byId[this.props.eventId]; if(event.timed_event) { - return ( - <div> - <input type="text" - id={id} - className="form-control" - autoFocus={autoFocus} - value={this.props.value} - ref={c => this.centisInput = c} - onChange={this.onChange} /> - (centiseconds) - </div> - ); + return <CentisecondsInput id={id} + autoFocus={autoFocus} + centiseconds={this.props.value} + onChange={this.onChange} + ref={c => this.centisecondsInput = c} + />; } else if(event.fewest_moves) { return ( <div> @@ -49,7 +101,7 @@ export default class extends React.Component { value={this.props.value} ref={c => this.movesInput = c} onChange={this.onChange} /> - (moves) + moves </div> ); } else if(event.multiple_blindfolded) { @@ -62,7 +114,7 @@ export default class extends React.Component { value={attemptResultToMbPoints(this.props.value)} ref={c => this.mbldPointsInput = c} onChange={this.onChange} /> - (mbld points) + points </div> ); } else { diff --git a/WcaOnRails/app/javascript/edit-events/modals/utils.js b/WcaOnRails/app/javascript/edit-events/modals/utils.js index 0c8d393fcf..d551ad36a9 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/utils.js +++ b/WcaOnRails/app/javascript/edit-events/modals/utils.js @@ -66,9 +66,11 @@ let pluralize = function(count, word, { fixed, abbreviate } = {}) { let countDesc = abbreviate ? word[0] : " " + (count == 1 ? word : word + "s"); return countStr + countDesc; } -const SECOND_IN_CS = 100; -const MINUTE_IN_CS = 60*SECOND_IN_CS; -const HOUR_IN_CS = 60*MINUTE_IN_CS; + +export const SECOND_IN_CS = 100; +export const MINUTE_IN_CS = 60*SECOND_IN_CS; +export const HOUR_IN_CS = 60*MINUTE_IN_CS; + export function centisecondsToString(centiseconds, { short } = {}) { let str = ""; From cd4b8e93497e718e5bbf24c9607c1cafb07a3ac5 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 1 Sep 2017 13:09:39 -0700 Subject: [PATCH 24/42] Created a matchResult() method to stringify what it takes to beat a result. --- .../edit-events/modals/AdvancementCondition.jsx | 6 +++--- .../app/javascript/edit-events/modals/Cutoff.jsx | 6 ++---- .../app/javascript/edit-events/modals/TimeLimit.jsx | 3 +-- .../app/javascript/edit-events/modals/utils.js | 12 ++++++++++++ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx index a27c1f8930..0578bfef69 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx @@ -1,7 +1,7 @@ import React from 'react' import AttemptResultInput from './AttemptResultInput' -import { attemptResultToString, roundIdToString } from './utils' +import { attemptResultToString, roundIdToString, matchResult } from './utils' export default { Title({ wcifRound }) { @@ -21,7 +21,7 @@ export default { return `Top ${advancementCondition.level}%`; break; case "attemptResult": - return attemptResultToString(advancementCondition.level, wcifEvent.id, { short: true }); + return matchResult(advancementCondition.level, wcifEvent.id, { short: true }); break; default: throw new Error(`Unrecognized advancementCondition type: ${advancementCondition.type}`); @@ -76,7 +76,7 @@ export default { break; case "attemptResult": advancementInput = <AttemptResultInput eventId={wcifEvent.id} value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} />; - helpBlock = `Everyone in round ${roundNumber} with a result better than or equal to ${attemptResultToString(advancementCondition.level, wcifEvent.id)} will advance to round ${roundNumber + 1}.`; + helpBlock = `Everyone in round ${roundNumber} with a result ${matchResult(advancementCondition.level, wcifEvent.id)} will advance to round ${roundNumber + 1}.`; break; default: advancementInput = null; diff --git a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx index 7f9b346021..6994001530 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx @@ -3,7 +3,7 @@ import React from 'react' import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' import AttemptResultInput from './AttemptResultInput' -import { attemptResultToString, roundIdToString } from './utils' +import { attemptResultToString, roundIdToString, matchResult } from './utils' export default { Title({ wcifRound }) { @@ -12,9 +12,7 @@ export default { Show({ value: cutoff, wcifEvent }) { let str; if(cutoff) { - let event = events.byId[wcifEvent.id]; - let comparisonString = event.multiple_blindfolded ? "≥" : "≤"; - str = `Best of ${cutoff.numberOfAttempts} ${comparisonString} ${attemptResultToString(cutoff.attemptResult, wcifEvent.id, { short: true })}`; + str = `Best of ${cutoff.numberOfAttempts} ${matchResult(cutoff.attemptResult, wcifEvent.id, { short: true })}`; } else { str = "-"; } diff --git a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx index ad2d996927..8fc18e4995 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx @@ -106,8 +106,7 @@ export default { This round has a cross round cumulative time limit (see guideline <a href="https://www.worldcubeassociation.org/regulations/guidelines.html#A1a2++" target="_blank">A1a2++</a>). This means that competitors have {centisecondsToString(timeLimit.centiseconds)} total for all - of their solves in this round ({wcifRound.id}) - {" "}<strong>shared with</strong>: + of their solves in this round ({wcifRound.id}) shared with: <ul> {otherSelectedRoundIds.map(roundId => <li key={roundId}>{roundIdToString(roundId)}</li>)} </ul> diff --git a/WcaOnRails/app/javascript/edit-events/modals/utils.js b/WcaOnRails/app/javascript/edit-events/modals/utils.js index d551ad36a9..05e396ce7f 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/utils.js +++ b/WcaOnRails/app/javascript/edit-events/modals/utils.js @@ -61,6 +61,18 @@ export function attemptResultToString(attemptResult, eventId, { short } = {}) { } } +export function matchResult(attemptResult, eventId, { short } = {}) { + let event = events.byId[eventId]; + let comparisonString = event.multiple_blindfolded ? "≥" : "≤"; + if(!short) { + comparisonString = { + "≤": "less than or equal to", + "≥": "greater than or equal to", + }[comparisonString]; + } + return `${comparisonString} ${attemptResultToString(attemptResult, eventId, { short })}`; +} + let pluralize = function(count, word, { fixed, abbreviate } = {}) { let countStr = (fixed && count % 1 > 0) ? count.toFixed(fixed) : count; let countDesc = abbreviate ? word[0] : " " + (count == 1 ? word : word + "s"); From 50a9133a8dee4032e2bb6b40a4587311c9ac9050 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 1 Sep 2017 15:18:19 -0700 Subject: [PATCH 25/42] Grey out events that are not being held. --- WcaOnRails/app/assets/stylesheets/edit_events.scss | 4 ++++ WcaOnRails/app/javascript/edit-events/EditEvents.jsx | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/WcaOnRails/app/assets/stylesheets/edit_events.scss b/WcaOnRails/app/assets/stylesheets/edit_events.scss index 09c584f611..ea813305f0 100644 --- a/WcaOnRails/app/assets/stylesheets/edit_events.scss +++ b/WcaOnRails/app/assets/stylesheets/edit_events.scss @@ -1,4 +1,8 @@ #events-edit-area { + .panel.event-not-being-held { + opacity: 0.5; + } + .panel-heading { padding-top: 10px; padding-bottom: 10px; diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index 90b7efcb55..bfd5f80dfd 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -1,6 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom' -import classNames from 'classnames' +import cn from 'classnames' import events from 'wca/events.js.erb' import { rootRender, promiseSaveWcif } from 'edit-events' @@ -73,7 +73,7 @@ export default class EditEvents extends React.Component { </div> <button onClick={this.save} disabled={this.state.saving} - className={classNames("btn", "btn-default", { "btn-primary": this.unsavedChanges(), saving: this.state.saving })} + className={cn("btn", "btn-default", { "btn-primary": this.unsavedChanges(), saving: this.state.saving })} > Update Competition </button> @@ -166,10 +166,10 @@ const EventPanel = ({ wcifEvents, wcifEvent }) => { }; return ( - <div className="panel panel-default"> + <div className={cn("panel panel-default", { 'event-not-being-held': wcifEvent.rounds.length == 0 })}> <div className="panel-heading"> <h3 className="panel-title"> - <span className={classNames("img-thumbnail", "cubing-icon", `event-${event.id}`)}></span> + <span className={cn("img-thumbnail", "cubing-icon", `event-${event.id}`)}></span> <span className="title">{event.name}</span> {" "} <select className="form-control input-xs" value={wcifEvent.rounds.length} onChange={roundCountChanged}> From cd305e45ac1589123baa8190cc39a5b0a0b02acf Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Fri, 1 Sep 2017 17:12:54 -0700 Subject: [PATCH 26/42] Implemented the UI for sharing a cumulative time limit with other rounds. --- .../edit-events/ButtonActivatedModal.jsx | 38 ++++ .../app/javascript/edit-events/EditEvents.jsx | 2 +- .../edit-events/modals/TimeLimit.jsx | 195 +++++++++++++----- .../javascript/edit-events/modals/index.jsx | 38 +--- 4 files changed, 184 insertions(+), 89 deletions(-) create mode 100644 WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx diff --git a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx new file mode 100644 index 0000000000..c54b99ee52 --- /dev/null +++ b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx @@ -0,0 +1,38 @@ +import React from 'react' +import cn from 'classnames' +import Modal from 'react-bootstrap/lib/Modal' +import Button from 'react-bootstrap/lib/Button' + +export default class extends React.Component { + constructor() { + super(); + this.state = { showModal: false }; + } + + open = () => { + this.setState({ showModal: true }); + } + + close = () => { + this.props.reset(); + this.setState({ showModal: false }); + } + + render() { + return ( + <button type="button" className={cn("btn", this.props.buttonClass)} onClick={this.open}> + {this.props.buttonValue} + <Modal show={this.state.showModal} onHide={this.close} backdrop="static"> + <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> + {this.props.children} + <Modal.Footer> + <Button onClick={this.close} className="pull-left">Close</Button> + <Button onClick={this.props.reset} bsStyle="danger" className="pull-left">Reset</Button> + <Button type="submit" bsStyle="primary">Save</Button> + </Modal.Footer> + </form> + </Modal> + </button> + ); + } +} diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index bfd5f80dfd..16c9795626 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -1,6 +1,6 @@ import React from 'react' -import ReactDOM from 'react-dom' import cn from 'classnames' +import ReactDOM from 'react-dom' import events from 'wca/events.js.erb' import { rootRender, promiseSaveWcif } from 'edit-events' diff --git a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx index 8fc18e4995..227ca31ebc 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx @@ -1,11 +1,14 @@ +import _ from 'lodash' import React from 'react' import ReactDOM from 'react-dom' +import Modal from 'react-bootstrap/lib/Modal' import Radio from 'react-bootstrap/lib/Radio' import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' import AttemptResultInput from './AttemptResultInput' import { centisecondsToString, roundIdToString } from './utils' +import ButtonActivatedModal from 'edit-events/ButtonActivatedModal' class RadioGroup extends React.Component { get value() { @@ -29,6 +32,124 @@ class RadioGroup extends React.Component { } } +function objectifyArray(arr) { + let obj = {}; + arr.forEach(el => obj[el] = true); + return obj; +} + +class SelectRoundsButton extends React.Component { + constructor(props) { + super(props); + this.state = { selectedRoundsById: objectifyArray(props.selectedRoundIds) }; + } + + componentWillReceiveProps(nextProps) { + this.setState({ selectedRoundsById: objectifyArray(nextProps.selectedRoundIds) }); + } + + reset = () => { + this.setState({ selectedRoundsById: objectifyArray(this.props.selectedRoundIds) }); + } + + getSelectedRoundIds() { + return Object.entries(this.state.selectedRoundsById).filter(([k, v]) => v).map(([k, v]) => k); + } + + onSave = () => { + this.props.onChange(); + this._modal.close(); + } + + render() { + let { timeLimit, excludeRound, wcifEvents } = this.props; + let selectedRoundsById = this.state.selectedRoundsById; + + let wcifRounds = []; + wcifEvents.forEach(otherWcifEvent => { + // Cross round cumulative time limits may not include other rounds of + // the same event. + // See https://github.com/thewca/wca-regulations/issues/457. + let excludeEventId = excludeRound.id.split("-")[0]; + let otherEvent = events.byId[otherWcifEvent.id]; + let canChangeTimeLimit = otherEvent.can_change_time_limit; + if(!canChangeTimeLimit || excludeEventId == otherWcifEvent.id) { + return; + } + wcifRounds = wcifRounds.concat(otherWcifEvent.rounds.filter(r => r != excludeRound)); + }); + + return ( + <ButtonActivatedModal + buttonValue="Share with other rounds" + buttonClass="btn-success" + onSave={this.onSave} + reset={this.reset} + ref={c => this._modal = c} + > + <Modal.Header closeButton> + <Modal.Title>Choose rounds for cumulative time limit</Modal.Title> + </Modal.Header> + <Modal.Body> + <div className="row"> + <div className="col-sm-offset-2 col-sm-10"> + <ul className="list-unstyled"> + {wcifRounds.map(wcifRound => { + let roundId = wcifRound.id; + let eventId = roundId.split("-")[0]; + let event = events.byId[eventId]; + let checked = !!selectedRoundsById[roundId]; + let eventAlreadySelected = this.getSelectedRoundIds().find(roundId => roundId.split("-")[0] == eventId); + let disabled = !checked && eventAlreadySelected; + let disabledReason = disabled && `Cannot select this round because you've already selected a round with ${event.name}`; + return ( + <li key={roundId}> + <div className="checkbox"> + <label title={disabledReason}> + <input type="checkbox" + value={roundId} + checked={checked} + disabled={disabled} + onChange={e => { + selectedRoundsById[wcifRound.id] = e.currentTarget.checked; + this.setState({ selectedRoundsById }); + }} + /> + {roundIdToString(roundId)} + </label> + </div> + </li> + ); + })} + </ul> + </div> + </div> + </Modal.Body> + </ButtonActivatedModal> + ); + } +} + +function RegulationLink({ regulation }) { + return ( + <span> + regulation <a href={`https://www.worldcubeassociation.org/regulations/#${regulation}`} target="_blank"> + {regulation} + </a> + </span> + ); +} + +function GuidelineLink({ guideline }) { + return ( + <span> + guideline <a href={`https://www.worldcubeassociation.org/regulations/guidelines.html#${guideline}`} target="_blank"> + {guideline} + </a> + </span> + ); +} + export default { Title({ wcifRound }) { return <span>Time limit for {roundIdToString(wcifRound.id)}</span>; @@ -54,21 +175,7 @@ export default { let wcifRound = wcifEvent.rounds[roundNumber - 1]; let format = formats.byId[wcifRound.format]; - let otherWcifRounds = []; - wcifEvents.forEach(otherWcifEvent => { - // Cross round cumulative time limits may not include other rounds of - // the same event. - // See https://github.com/thewca/wca-regulations/issues/457. - let otherEvent = events.byId[otherWcifEvent.id]; - let canChangeTimeLimit = otherEvent.can_change_time_limit; - if(!canChangeTimeLimit || wcifEvent == otherWcifEvent) { - return; - } - otherWcifRounds = otherWcifRounds.concat(otherWcifEvent.rounds.filter(r => r != wcifRound)); - }); - - let centisInput, cumulativeInput, cumulativeRadio; - let roundCheckboxes = []; + let centisInput, cumulativeInput, cumulativeRadio, roundsSelector; let onChangeAggregator = () => { let cumulativeRoundIds; switch(cumulativeRadio.value) { @@ -77,7 +184,9 @@ export default { break; case "cumulative": cumulativeRoundIds = [wcifRound.id]; - cumulativeRoundIds = cumulativeRoundIds.concat(roundCheckboxes.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value)); + if(roundsSelector) { + cumulativeRoundIds = _.uniq(cumulativeRoundIds.concat(roundsSelector.getSelectedRoundIds())); + } break; default: throw new Error(`Unrecognized value ${cumulativeRadio.value}`); @@ -91,25 +200,38 @@ export default { onChange(newTimeLimit); }; + let selectRoundsButton = ( + <SelectRoundsButton onChange={onChangeAggregator} + wcifEvents={wcifEvents} + excludeRound={wcifRound} + selectedRoundIds={timeLimit.cumulativeRoundIds} + ref={c => roundsSelector = c} + /> + ); + let description = null; if(timeLimit.cumulativeRoundIds.length === 0) { description = `Competitors have ${centisecondsToString(timeLimit.centiseconds)} for each of their solves.`; } else if(timeLimit.cumulativeRoundIds.length === 1) { description = (<span> Competitors have {centisecondsToString(timeLimit.centiseconds)} total for all - of their solves in this round. This is called a cumulative time limit (see - regulation <a href="https://www.worldcubeassociation.org/regulations/#A1a2" target="_blank">A1a2</a>). + of their solves in this round. This is called a cumulative time limit + (see <RegulationLink regulation="A1a2" />). + The button below allows you to share this cumulative time limit with other rounds + (see <GuidelineLink guideline="A1a2++" />). + <div>{selectRoundsButton}</div> </span>); } else { let otherSelectedRoundIds = timeLimit.cumulativeRoundIds.filter(roundId => roundId != wcifRound.id); description = (<span> - This round has a cross round cumulative time limit (see - guideline <a href="https://www.worldcubeassociation.org/regulations/guidelines.html#A1a2++" target="_blank">A1a2++</a>). + This round has a cross round cumulative time limit + (see <GuidelineLink guideline="A1a2++" />). This means that competitors have {centisecondsToString(timeLimit.centiseconds)} total for all of their solves in this round ({wcifRound.id}) shared with: <ul> {otherSelectedRoundIds.map(roundId => <li key={roundId}>{roundIdToString(roundId)}</li>)} </ul> + {selectRoundsButton} </span>); } @@ -141,39 +263,6 @@ export default { </div> </div> - {timeLimit.cumulativeRoundIds.length >= 1 && ( - <div className="row"> - <div className="col-sm-offset-2 col-sm-10"> - <ul className="list-unstyled"> - {otherWcifRounds.map(wcifRound => { - let roundId = wcifRound.id; - let eventId = roundId.split("-")[0]; - let event = events.byId[eventId]; - let checked = timeLimit.cumulativeRoundIds.indexOf(roundId) >= 0; - let eventAlreadySelected = timeLimit.cumulativeRoundIds.find(roundId => roundId.split("-")[0] == eventId); - let disabled = !checked && eventAlreadySelected; - let disabledReason = disabled && `Cannot select this round because you've already selected a round with ${event.name}`; - return ( - <li key={roundId}> - <div className="checkbox"> - <label title={disabledReason}> - <input type="checkbox" - value={roundId} - checked={checked} - disabled={disabled} - ref={c => roundCheckboxes.push(c) } - onChange={onChangeAggregator} /> - {roundIdToString(roundId)} - </label> - </div> - </li> - ); - })} - </ul> - </div> - </div> - )} - <div className="row"> <span className="col-sm-offset-2 col-sm-10">{description}</span> </div> diff --git a/WcaOnRails/app/javascript/edit-events/modals/index.jsx b/WcaOnRails/app/javascript/edit-events/modals/index.jsx index fb4559b9bf..498e4ab98f 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/index.jsx @@ -1,4 +1,5 @@ import React from 'react' +import cn from 'classnames' import Modal from 'react-bootstrap/lib/Modal' import Button from 'react-bootstrap/lib/Button' import Checkbox from 'react-bootstrap/lib/Checkbox' @@ -10,6 +11,7 @@ import { rootRender } from 'edit-events' import CutoffComponents from './Cutoff' import TimeLimitComponents from './TimeLimit' import AdvancementConditionComponents from './AdvancementCondition' +import ButtonActivatedModal from 'edit-events/ButtonActivatedModal' let RoundAttributeComponents = { timeLimit: TimeLimitComponents, @@ -45,41 +47,6 @@ function findRounds(wcifEvents, roundIds) { return wcifRounds; } -class ButtonActivatedModal extends React.Component { - constructor() { - super(); - this.state = { showModal: false }; - } - - open = () => { - this.setState({ showModal: true }); - } - - close = () => { - this.props.reset(); - this.setState({ showModal: false }); - } - - render() { - return ( - <button type="button" className="btn btn-default btn-xs" - onClick={this.open}> - {this.props.buttonValue} - <Modal show={this.state.showModal} onHide={this.close} backdrop="static"> - <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> - {this.props.children} - <Modal.Footer> - <Button onClick={this.close} className="pull-left">Close</Button> - <Button onClick={this.props.reset} bsStyle="danger" className="pull-left">Reset</Button> - <Button type="submit" bsStyle="primary">Save</Button> - </Modal.Footer> - </form> - </Modal> - </button> - ); - } -} - class EditRoundAttribute extends React.Component { componentWillMount() { this.reset(); @@ -148,6 +115,7 @@ class EditRoundAttribute extends React.Component { return ( <ButtonActivatedModal buttonValue={<Show value={this.getSavedValue()} wcifEvent={wcifEvent} />} + buttonClass="btn-default btn-xs" formClass="form-horizontal" onSave={this.onSave} reset={this.reset} From 1dcf6468ba0df1805bc34981a0f10e8f8490fcb3 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Sun, 3 Sep 2017 01:30:59 -0700 Subject: [PATCH 27/42] Added javascript feature test of rounds management. --- .../edit-events/ButtonActivatedModal.jsx | 2 +- .../app/javascript/edit-events/EditEvents.jsx | 8 +- .../app/javascript/edit-events/index.jsx | 4 +- .../modals/AdvancementCondition.jsx | 5 +- .../edit-events/modals/AttemptResultInput.jsx | 7 +- .../javascript/edit-events/modals/index.jsx | 9 +- .../app/javascript/packs/application.js | 1 + WcaOnRails/app/javascript/polyfills/index.js | 50 +++++++++ WcaOnRails/package.json | 8 +- .../spec/features/competition_events_spec.rb | 106 ++++++++++++++++++ WcaOnRails/spec/models/round_spec.rb | 12 +- WcaOnRails/yarn.lock | 6 +- 12 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 WcaOnRails/app/javascript/polyfills/index.js create mode 100644 WcaOnRails/spec/features/competition_events_spec.rb diff --git a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx index c54b99ee52..e0891911d0 100644 --- a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx +++ b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx @@ -20,7 +20,7 @@ export default class extends React.Component { render() { return ( - <button type="button" className={cn("btn", this.props.buttonClass)} onClick={this.open}> + <button type="button" name={this.props.name} className={cn("btn", this.props.buttonClass)} onClick={this.open}> {this.props.buttonValue} <Modal show={this.state.showModal} onHide={this.close} backdrop="static"> <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index 16c9795626..c10d8fb121 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -113,10 +113,10 @@ function RoundsTable({ wcifEvents, wcifEvent }) { }; return ( - <tr key={roundNumber}> + <tr key={roundNumber} className={`round-${roundNumber}`}> <td>{roundNumber}</td> <td> - <select className="form-control input-xs" value={wcifRound.format} onChange={roundFormatChanged}> + <select name="format" className="form-control input-xs" value={wcifRound.format} onChange={roundFormatChanged}> {event.formats().map(format => <option key={format.id} value={format.id}>{abbreviate(format.name)}</option>)} </select> </td> @@ -166,13 +166,13 @@ const EventPanel = ({ wcifEvents, wcifEvent }) => { }; return ( - <div className={cn("panel panel-default", { 'event-not-being-held': wcifEvent.rounds.length == 0 })}> + <div className={cn(`panel panel-default event-${wcifEvent.id}`, { 'event-not-being-held': wcifEvent.rounds.length == 0 })}> <div className="panel-heading"> <h3 className="panel-title"> <span className={cn("img-thumbnail", "cubing-icon", `event-${event.id}`)}></span> <span className="title">{event.name}</span> {" "} - <select className="form-control input-xs" value={wcifEvent.rounds.length} onChange={roundCountChanged}> + <select className="form-control input-xs" name="select-round-count" value={wcifEvent.rounds.length} onChange={roundCountChanged}> <option value={0}>Not being held</option> <option disabled="disabled">────────</option> <option value={1}>1 round</option> diff --git a/WcaOnRails/app/javascript/edit-events/index.jsx b/WcaOnRails/app/javascript/edit-events/index.jsx index fabda22f5f..5f138e1111 100644 --- a/WcaOnRails/app/javascript/edit-events/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/index.jsx @@ -11,10 +11,10 @@ function getAuthenticityToken() { export function promiseSaveWcif(wcif) { let url = `/competitions/${wcif.id}/wcif/events`; let fetchOptions = { - headers: new Headers({ + headers: { "Content-Type": "application/json", "X-CSRF-Token": getAuthenticityToken(), - }), + }, credentials: 'include', method: "PATCH", body: JSON.stringify(wcif.events), diff --git a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx index 0578bfef69..2eb30f970a 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx @@ -67,11 +67,11 @@ export default { let advancementType = advancementCondition ? advancementCondition.type : ""; switch(advancementType) { case "ranking": - advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} />; + advancementInput = <input type="number" name="ranking" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} />; helpBlock = `The top ${advancementCondition.level} competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; break; case "percent": - advancementInput = <input type="number" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} />; + advancementInput = <input type="number" name="percent" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} />; helpBlock = `The top ${advancementCondition.level}% of competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; break; case "attemptResult": @@ -89,6 +89,7 @@ export default { <div className="col-sm-12"> <div className="input-group advancement-condition"> <select value={advancementCondition ? advancementCondition.type : ""} + name="type" autoFocus={autoFocus} onChange={onChangeAggregator} className="form-control" diff --git a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx index 21d7a346f9..f40de5e54e 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx @@ -29,6 +29,7 @@ class CentisecondsInput extends React.Component { <div> <input type="number" id={id} + name="minutes" className="form-control" autoFocus={autoFocus} value={minutes} @@ -38,9 +39,8 @@ class CentisecondsInput extends React.Component { minutes <input type="number" - id={id} + name="seconds" className="form-control" - autoFocus={autoFocus} value={seconds} min={0} max={59} ref={c => this.secondsInput = c} @@ -48,9 +48,8 @@ class CentisecondsInput extends React.Component { seconds <input type="number" - id={id} + name="centiseconds" className="form-control" - autoFocus={autoFocus} value={centiseconds} min={0} max={99} ref={c => this.centisecondsInput = c} diff --git a/WcaOnRails/app/javascript/edit-events/modals/index.jsx b/WcaOnRails/app/javascript/edit-events/modals/index.jsx index 498e4ab98f..bded57620b 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/index.jsx @@ -106,15 +106,16 @@ class EditRoundAttribute extends React.Component { } render() { - let { wcifEvents, wcifEvent, roundNumber } = this.props; + let { wcifEvents, wcifEvent, roundNumber, attribute } = this.props; let wcifRound = this.getWcifRound(); - let Show = RoundAttributeComponents[this.props.attribute].Show; - let Input = RoundAttributeComponents[this.props.attribute].Input; - let Title = RoundAttributeComponents[this.props.attribute].Title; + let Show = RoundAttributeComponents[attribute].Show; + let Input = RoundAttributeComponents[attribute].Input; + let Title = RoundAttributeComponents[attribute].Title; return ( <ButtonActivatedModal buttonValue={<Show value={this.getSavedValue()} wcifEvent={wcifEvent} />} + name={attribute} buttonClass="btn-default btn-xs" formClass="form-horizontal" onSave={this.onSave} diff --git a/WcaOnRails/app/javascript/packs/application.js b/WcaOnRails/app/javascript/packs/application.js index 1afc6428b7..e4693c3f6c 100644 --- a/WcaOnRails/app/javascript/packs/application.js +++ b/WcaOnRails/app/javascript/packs/application.js @@ -9,3 +9,4 @@ import 'markdown-editor'; import 'image-preview'; +import 'polyfills'; diff --git a/WcaOnRails/app/javascript/polyfills/index.js b/WcaOnRails/app/javascript/polyfills/index.js new file mode 100644 index 0000000000..cdbe015107 --- /dev/null +++ b/WcaOnRails/app/javascript/polyfills/index.js @@ -0,0 +1,50 @@ +import 'whatwg-fetch'; +import Promise from 'promise-polyfill'; +if(!window.Promise) { + window.Promise = Promise; +} + +// https://tc39.github.io/ecma262/#sec-array.prototype.find +if (!Array.prototype.find) { + Object.defineProperty(Array.prototype, 'find', { + value: function(predicate) { + // 1. Let O be ? ToObject(this value). + if (this === null) { + throw new TypeError('"this" is null or not defined'); + } + + var o = Object(this); + + // 2. Let len be ? ToLength(? Get(O, "length")). + var len = o.length >>> 0; + + // 3. If IsCallable(predicate) is false, throw a TypeError exception. + if (typeof predicate !== 'function') { + throw new TypeError('predicate must be a function'); + } + + // 4. If thisArg was supplied, let T be thisArg; else let T be undefined. + var thisArg = arguments[1]; + + // 5. Let k be 0. + var k = 0; + + // 6. Repeat, while k < len + while (k < len) { + // a. Let Pk be ! ToString(k). + // b. Let kValue be ? Get(O, Pk). + // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)). + // d. If testResult is true, return kValue. + var kValue = o[k]; + if (predicate.call(thisArg, kValue, k, o)) { + return kValue; + } + // e. Increase k by 1. + k++; + } + + // 7. Return undefined. + return undefined; + } + }); +} diff --git a/WcaOnRails/package.json b/WcaOnRails/package.json index 374659e3af..c8fbce3cf9 100644 --- a/WcaOnRails/package.json +++ b/WcaOnRails/package.json @@ -8,9 +8,9 @@ "babel-plugin-transform-class-properties": "^6.24.1", "babel-polyfill": "^6.23.0", "babel-preset-env": "^1.6.0", - "blueimp-load-image": "^2.14.0", "babel-preset-react": "^6.24.1", "babel-preset-stage-2": "^6.24.1", + "blueimp-load-image": "^2.14.0", "classnames": "^2.2.5", "coffee-loader": "^0.8.0", "coffee-script": "^1.12.7", @@ -25,18 +25,20 @@ "postcss-loader": "^2.0.6", "postcss-smart-import": "^0.7.5", "precss": "^2.0.0", + "promise-polyfill": "^6.0.2", "prop-types": "^15.5.8", "rails-erb-loader": "^5.0.2", - "resolve-url-loader": "^2.1.0", "react": "^15.5.4", "react-bootstrap": "^0.31.1", "react-dom": "^15.5.4", + "resolve-url-loader": "^2.1.0", "sass-loader": "^6.0.6", "simplemde": "^1.11.2", "style-loader": "^0.18.2", "webpack": "^3.5.6", "webpack-manifest-plugin": "^1.3.2", - "webpack-merge": "^4.1.0" + "webpack-merge": "^4.1.0", + "whatwg-fetch": "^2.0.3" }, "devDependencies": { "webpack-dev-server": "^2.8.2" diff --git a/WcaOnRails/spec/features/competition_events_spec.rb b/WcaOnRails/spec/features/competition_events_spec.rb new file mode 100644 index 0000000000..dbb98ab916 --- /dev/null +++ b/WcaOnRails/spec/features/competition_events_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.feature "Competition events management" do + let(:competition) { FactoryGirl.create(:competition, event_ids: []) } + + before :each do + # Enable CSRF protection just for these tests. + # See https://blog.tomoyukikashiro.me/post/test-csrf-in-feature-test-using-capybara/ + allow_any_instance_of(ActionController::Base).to receive(:protect_against_forgery?).and_return(true) + end + + background do + sign_in FactoryGirl.create(:admin) + visit "/competitions/#{competition.id}/events/edit" + within_event_panel("333") { select("1 round", from: "select-round-count") } + save + competition.reload + end + + scenario "adds 1 round of 333", js: true do + expect(competition.events.map(&:id)).to match_array %w(333) + end + + feature 'change round attributes' do + let(:comp_event_333) { competition.competition_events.find_by_event_id("333") } + let(:round_333_1) { comp_event_333.rounds.first } + + scenario "change to best of 3", js: true do + within_round("333", 1) { select("Bo3", from: "format") } + save + expect(round_333_1.reload.format.id).to eq "3" + end + + scenario "change time limit to 5 minutes", js: true do + within_round("333", 1) { find("[name=timeLimit]").click } + + within_modal do + fill_in "minutes", with: "5" + click_button "Save" + end + save + + expect(round_333_1.reload.time_limit).to eq TimeLimit.new(centiseconds: 5.minutes.in_centiseconds, cumulative_round_ids: []) + expect(round_333_1.reload.time_limit.to_s(round_333_1)).to eq "5:00.00" + end + + scenario "change cutoff to best of 2 in 2 minutes", js: true do + within_round("333", 1) { find("[name=cutoff]").click } + + within_modal do + select "Best of 2", from: "Round format" + fill_in "minutes", with: "2" + click_button "Save" + end + save + + expect(round_333_1.reload.cutoff.to_s(round_333_1)).to eq "2 attempts to get ≤ 2:00.00" + end + + scenario "change advancement condition to top 12 people", js: true do + # Add a second round of 333 so we can set an advancement condition on round 1. + within_event_panel("333") { select("2 rounds", from: "select-round-count") } + + within_round("333", 1) { find("[name=advancementCondition]").click } + + within_modal do + select "Ranking", from: "type" + fill_in "ranking", with: "12" + click_button "Save" + end + save + + expect(round_333_1.reload.advancement_condition.to_s(round_333_1)).to eq "Top 12 advance to round 2" + end + end +end + +def within_event_panel(event_id) + within(:css, ".panel.event-#{event_id}") do + yield + end +end + +def within_round(event_id, round_number) + within_event_panel(event_id) do + within(:css, ".round-1") do + yield + end + end +end + +def within_modal + within(:css, '.modal-content') do + yield + end +end + +def save + click_button "Update Competition" + # Wait for ajax to complete. + # Clicking the button disables it, and capybara won't find the button + # until it's clickable again (after the ajax has succeeded). + find_button "Update Competition" +end diff --git a/WcaOnRails/spec/models/round_spec.rb b/WcaOnRails/spec/models/round_spec.rb index 5d03ff6db9..c96dd4bf03 100644 --- a/WcaOnRails/spec/models/round_spec.rb +++ b/WcaOnRails/spec/models/round_spec.rb @@ -28,19 +28,19 @@ let!(:five_blind_round) { FactoryGirl.create :round, competition: competition, event_id: "555bf", format_id: "3" } it "defaults to 10 minutes" do - expect(round.time_limit).to eq(TimeLimit.new(centiseconds: 10*60*100, cumulative_round_ids: [])) + expect(round.time_limit).to eq(TimeLimit.new(centiseconds: 10.minutes.in_centiseconds, cumulative_round_ids: [])) expect(round.time_limit_to_s).to eq "10:00.00" end it "set to 5 minutes" do - round.update!(time_limit: TimeLimit.new(centiseconds: 5*60*100, cumulative_round_ids: ["333-1"])) - expect(round.time_limit.centiseconds).to eq 5*60*100 + round.update!(time_limit: TimeLimit.new(centiseconds: 5.minutes.in_centiseconds, cumulative_round_ids: ["333-1"])) + expect(round.time_limit.centiseconds).to eq 5.minutes.in_centiseconds expect(round.time_limit_to_s).to eq "5:00.00 cumulative" end it "set to 60 minutes shared between 444bf and 555bf" do - four_blind_round.update!(time_limit: TimeLimit.new(centiseconds: 5*60*100, cumulative_round_ids: ["444bf-1", "555bf-1"])) - expect(four_blind_round.time_limit.centiseconds).to eq 5*60*100 + four_blind_round.update!(time_limit: TimeLimit.new(centiseconds: 5.minutes.in_centiseconds, cumulative_round_ids: ["444bf-1", "555bf-1"])) + expect(four_blind_round.time_limit.centiseconds).to eq 5.minutes.in_centiseconds expect(four_blind_round.time_limit_to_s).to eq "5:00.00 total for 4x4x4 Blindfolded Round 1 and 5x5x5 Blindfolded Round 1" end end @@ -140,7 +140,7 @@ it "set to <= 3 minutes" do first_round, _second_round = create_rounds("333", count: 2) - first_round.update!(advancement_condition: AttemptResultCondition.new(3*60*100)) + first_round.update!(advancement_condition: AttemptResultCondition.new(3.minutes.in_centiseconds)) expect(first_round.advancement_condition_to_s).to eq "Best solve ≤ 3:00.00 advances to round 2" end diff --git a/WcaOnRails/yarn.lock b/WcaOnRails/yarn.lock index 455075aab1..6c650eb7e4 100644 --- a/WcaOnRails/yarn.lock +++ b/WcaOnRails/yarn.lock @@ -4340,6 +4340,10 @@ promise-each@^2.2.0: dependencies: any-promise "^0.1.0" +promise-polyfill@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-6.0.2.tgz#d9c86d3dc4dc2df9016e88946defd69b49b41162" + promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -5591,7 +5595,7 @@ websocket-extensions@>=0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.2.tgz#0e18781de629a18308ce1481650f67ffa2693a5d" -whatwg-fetch@>=0.10.0: +whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" From 6bf146baf3562616ef897c776a453df89b3430a2 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Sun, 3 Sep 2017 01:50:50 -0700 Subject: [PATCH 28/42] Polish the look of the advancement condition modal. --- .../app/assets/stylesheets/edit_events.scss | 8 --- .../modals/AdvancementCondition.jsx | 52 ++++++++++++------- .../spec/features/competition_events_spec.rb | 4 +- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/WcaOnRails/app/assets/stylesheets/edit_events.scss b/WcaOnRails/app/assets/stylesheets/edit_events.scss index ea813305f0..ea95fffb77 100644 --- a/WcaOnRails/app/assets/stylesheets/edit_events.scss +++ b/WcaOnRails/app/assets/stylesheets/edit_events.scss @@ -35,11 +35,3 @@ } } } - -.input-group.advancement-condition { - width: 100%; - - .form-control { - width: 50%; - } -} diff --git a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx index 2eb30f970a..5b2c08951a 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx @@ -65,17 +65,21 @@ export default { let advancementInput = null; let helpBlock = null; let advancementType = advancementCondition ? advancementCondition.type : ""; + let valueLabel = null; switch(advancementType) { case "ranking": - advancementInput = <input type="number" name="ranking" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} />; + valueLabel = "Ranking"; + advancementInput = <input type="number" id="advacement-condition-value" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} />; helpBlock = `The top ${advancementCondition.level} competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; break; case "percent": - advancementInput = <input type="number" name="percent" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} />; + valueLabel = "Percent"; + advancementInput = <input type="number" id="advacement-condition-value" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} />; helpBlock = `The top ${advancementCondition.level}% of competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; break; case "attemptResult": - advancementInput = <AttemptResultInput eventId={wcifEvent.id} value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} />; + valueLabel = "Result"; + advancementInput = <AttemptResultInput id="advacement-condition-value" eventId={wcifEvent.id} value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} />; helpBlock = `Everyone in round ${roundNumber} with a result ${matchResult(advancementCondition.level, wcifEvent.id)} will advance to round ${roundNumber + 1}.`; break; default: @@ -86,26 +90,34 @@ export default { return ( <div> <div className="form-group"> - <div className="col-sm-12"> - <div className="input-group advancement-condition"> - <select value={advancementCondition ? advancementCondition.type : ""} - name="type" - autoFocus={autoFocus} - onChange={onChangeAggregator} - className="form-control" - ref={c => typeInput = c} - > - <option value="">To be announced</option> - <option disabled="disabled">────────</option> - <option value="ranking">Ranking</option> - <option value="percent">Percent</option> - <option value="attemptResult">Result</option> - </select> + <label htmlFor="advacement-condition-type" className="col-sm-3 control-label">Type</label> + <div className="col-sm-9"> + <select value={advancementCondition ? advancementCondition.type : ""} + id="advacement-condition-type" + name="type" + autoFocus={autoFocus} + onChange={onChangeAggregator} + className="form-control" + ref={c => typeInput = c} + > + <option value="">To be announced</option> + <option disabled="disabled">────────</option> + <option value="ranking">Ranking</option> + <option value="percent">Percent</option> + <option value="attemptResult">Result</option> + </select> + </div> + </div> - {advancementInput} - </div> + <div className="form-group"> + <label htmlFor="advacement-condition-value" className="col-sm-3 control-label"> + {valueLabel} + </label> + <div className="col-sm-9"> + {advancementInput} </div> </div> + {helpBlock} </div> ); diff --git a/WcaOnRails/spec/features/competition_events_spec.rb b/WcaOnRails/spec/features/competition_events_spec.rb index dbb98ab916..c417d2215e 100644 --- a/WcaOnRails/spec/features/competition_events_spec.rb +++ b/WcaOnRails/spec/features/competition_events_spec.rb @@ -66,8 +66,8 @@ within_round("333", 1) { find("[name=advancementCondition]").click } within_modal do - select "Ranking", from: "type" - fill_in "ranking", with: "12" + select "Ranking", from: "Type" + fill_in "Ranking", with: "12" click_button "Save" end save From 639e7dbf9f210739d109729bdd61bf5eab6daa13 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 7 Sep 2017 08:22:12 -0700 Subject: [PATCH 29/42] Limit the advancement percent to 75%. --- .../edit-events/modals/AdvancementCondition.jsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx index 5b2c08951a..47249655fd 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx @@ -3,6 +3,9 @@ import React from 'react' import AttemptResultInput from './AttemptResultInput' import { attemptResultToString, roundIdToString, matchResult } from './utils' +const MIN_ADVANCE_PERCENT = 1; +const MAX_ADVANCE_PERCENT = 75; + export default { Title({ wcifRound }) { return <span>Requirement to advance past {roundIdToString(wcifRound.id)}</span>; @@ -74,7 +77,17 @@ export default { break; case "percent": valueLabel = "Percent"; - advancementInput = <input type="number" id="advacement-condition-value" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => percentInput = c} />; + advancementInput = ( + <input type="number" + id="advacement-condition-value" + min={MIN_ADVANCE_PERCENT} + max={MAX_ADVANCE_PERCENT} + className="form-control" + value={advancementCondition.level} + onChange={onChangeAggregator} + ref={c => percentInput = c} + /> + ); helpBlock = `The top ${advancementCondition.level}% of competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; break; case "attemptResult": From d669a51db7d73d8a049a6e3c974582a8ee4376be Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 7 Sep 2017 08:56:24 -0700 Subject: [PATCH 30/42] Add nice big alerts notifying the user when there are unsaved changes. --- .../app/javascript/edit-events/EditEvents.jsx | 22 ++++++++++++++----- .../spec/features/competition_events_spec.rb | 6 ++--- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index c10d8fb121..c1bdcbc670 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -60,8 +60,23 @@ export default class EditEvents extends React.Component { render() { let { competitionId, wcifEvents } = this.props; + let unsavedChanges = null; + if(this.unsavedChanges()) { + unsavedChanges = ( + <div className="alert alert-info"> + You have unsaved changes. Don't forget to{" "} + <button onClick={this.save} + disabled={this.state.saving} + className={cn("btn", "btn-default btn-primary", { saving: this.state.saving })} + > + save your changes! + </button> + </div> + ); + } return ( <div> + {unsavedChanges} <div className="row equal"> {wcifEvents.map(wcifEvent => { return ( @@ -71,12 +86,7 @@ export default class EditEvents extends React.Component { ); })} </div> - <button onClick={this.save} - disabled={this.state.saving} - className={cn("btn", "btn-default", { "btn-primary": this.unsavedChanges(), saving: this.state.saving })} - > - Update Competition - </button> + {unsavedChanges} </div> ); } diff --git a/WcaOnRails/spec/features/competition_events_spec.rb b/WcaOnRails/spec/features/competition_events_spec.rb index c417d2215e..9c2b9a47d3 100644 --- a/WcaOnRails/spec/features/competition_events_spec.rb +++ b/WcaOnRails/spec/features/competition_events_spec.rb @@ -98,9 +98,7 @@ def within_modal end def save - click_button "Update Competition" + first(:button, "save your changes!", visible: true).click # Wait for ajax to complete. - # Clicking the button disables it, and capybara won't find the button - # until it's clickable again (after the ajax has succeeded). - find_button "Update Competition" + expect(page).to have_no_content("You have unsaved changes") end From dd5f8d490ab38033b3afcbbde45e444544087d42 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 7 Sep 2017 09:33:44 -0700 Subject: [PATCH 31/42] Added some explanation text to the cutoff modal. --- .../app/javascript/edit-events/modals/Cutoff.jsx | 15 ++++++++++++++- .../app/javascript/edit-events/modals/utils.js | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx index 6994001530..ccc2e2533a 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx @@ -3,7 +3,12 @@ import React from 'react' import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' import AttemptResultInput from './AttemptResultInput' -import { attemptResultToString, roundIdToString, matchResult } from './utils' +import { + pluralize, + matchResult, + roundIdToString, + attemptResultToString, +} from './utils' export default { Title({ wcifRound }) { @@ -36,6 +41,12 @@ export default { onChange(newCutoff); }; + let explanationText = null; + if(cutoff) { + explanationText = `Competitors get ${pluralize(cutoff.numberOfAttempts, "attempt")} to get ${matchResult(cutoff.attemptResult, wcifEvent.id)}.`; + explanationText += ` If they succeed, they get to do all ${formats.byId[wcifRound.format].expected_solve_count} solves.`; + } + return ( <div> <div className="form-group"> @@ -74,6 +85,8 @@ export default { </div> </div> )} + + {explanationText} </div> ); }, diff --git a/WcaOnRails/app/javascript/edit-events/modals/utils.js b/WcaOnRails/app/javascript/edit-events/modals/utils.js index 05e396ce7f..046a3c9a78 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/utils.js +++ b/WcaOnRails/app/javascript/edit-events/modals/utils.js @@ -73,7 +73,7 @@ export function matchResult(attemptResult, eventId, { short } = {}) { return `${comparisonString} ${attemptResultToString(attemptResult, eventId, { short })}`; } -let pluralize = function(count, word, { fixed, abbreviate } = {}) { +export function pluralize(count, word, { fixed, abbreviate } = {}) { let countStr = (fixed && count % 1 > 0) ? count.toFixed(fixed) : count; let countDesc = abbreviate ? word[0] : " " + (count == 1 ? word : word + "s"); return countStr + countDesc; From 9e686541edeb285d29158a1b9ec6eaa8618f3eaa Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 7 Sep 2017 09:38:00 -0700 Subject: [PATCH 32/42] Remove upper bound of 60 minutes on time limits. --- .../app/javascript/edit-events/modals/AttemptResultInput.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx index f40de5e54e..e28bfa9c0c 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx @@ -33,7 +33,7 @@ class CentisecondsInput extends React.Component { className="form-control" autoFocus={autoFocus} value={minutes} - min={0} max={60} + min={0} ref={c => this.minutesInput = c} onChange={onChange} /> minutes From abad79d9fc0f50d14e94b9c1ae3136ff27788036 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 7 Sep 2017 13:49:35 -0700 Subject: [PATCH 33/42] Do not allow adding or removing events to a confirmed competition. Also updated the error message dialog to include the error from the server, so the user has some idea what's going on. --- .../controllers/competitions_controller.rb | 2 + .../app/javascript/edit-events/EditEvents.jsx | 10 +- WcaOnRails/app/models/competition.rb | 19 ++- WcaOnRails/app/models/competition_event.rb | 5 - WcaOnRails/spec/requests/competitions_spec.rb | 122 ++++++++++++++++++ 5 files changed, 145 insertions(+), 13 deletions(-) diff --git a/WcaOnRails/app/controllers/competitions_controller.rb b/WcaOnRails/app/controllers/competitions_controller.rb index 0ae5253990..e85b5e704b 100644 --- a/WcaOnRails/app/controllers/competitions_controller.rb +++ b/WcaOnRails/app/controllers/competitions_controller.rb @@ -352,6 +352,8 @@ def update_events_from_wcif status: "Error while saving WCIF events", error: e.message, } + rescue WcaExceptions::ApiException => e + render status: e.status, json: { error: e.to_s } end def get_nearby_competitions(competition) diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index c1bdcbc670..553820fabc 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -22,15 +22,15 @@ export default class EditEvents extends React.Component { this.setState({ saving: true }); promiseSaveWcif(wcif).then(response => { + return response.json().then(json => [ response, json ]); + }).then(([response, json]) => { if(!response.ok) { - throw new Error(`${response.status}: ${response.statusText}`); + throw new Error(`${response.status}: ${response.statusText}\n${json["error"]}`); } - return response; - }).then(() => { this.setState({ savedWcifEvents: clone(this.props.wcifEvents), saving: false }); - }).catch(() => { + }).catch(e => { this.setState({ saving: false }); - alert("Something went wrong while saving."); + alert("Something went wrong while saving.\n" + e.message); }); } diff --git a/WcaOnRails/app/models/competition.rb b/WcaOnRails/app/models/competition.rb index ef36dd8071..e3304e3b76 100644 --- a/WcaOnRails/app/models/competition.rb +++ b/WcaOnRails/app/models/competition.rb @@ -850,17 +850,30 @@ def set_wcif_events!(wcif_events) ActiveRecord::Base.transaction do # Remove extra events. self.competition_events.each do |competition_event| - competition_event.destroy! unless wcif_events.find { |wcif_event| wcif_event["id"] == competition_event.event.id } + wcif_event = wcif_events.find { |e| e["id"] == competition_event.event.id } + event_to_be_removed = !wcif_event || !wcif_event["rounds"] || wcif_event["rounds"].empty? + if event_to_be_removed + raise WcaExceptions::BadApiParameter.new("Cannot remove events from a confirmed competition") if self.isConfirmed? + competition_event.destroy! + end end # Create missing events. wcif_events.each do |wcif_event| - competition_events.find_or_create_by!(event_id: wcif_event["id"]) + event_found = competition_events.find_by_event_id(wcif_event["id"]) + event_to_be_added = wcif_event["rounds"] && !wcif_event["rounds"].empty? + if !event_found && event_to_be_added + raise WcaExceptions::BadApiParameter.new("Cannot add events to a confirmed competition") if self.isConfirmed? + competition_events.create!(event_id: wcif_event["id"]) + end end # Update all events. wcif_events.each do |wcif_event| - competition_events.find_by_event_id!(wcif_event["id"]).load_wcif!(wcif_event) + event_to_be_added = wcif_event["rounds"] && !wcif_event["rounds"].empty? + if event_to_be_added + competition_events.find_by_event_id!(wcif_event["id"]).load_wcif!(wcif_event) + end end end diff --git a/WcaOnRails/app/models/competition_event.rb b/WcaOnRails/app/models/competition_event.rb index e0958b155f..f5aeec7f99 100644 --- a/WcaOnRails/app/models/competition_event.rb +++ b/WcaOnRails/app/models/competition_event.rb @@ -40,11 +40,6 @@ def to_wcif end def load_wcif!(wcif) - if wcif["rounds"].empty? - self.destroy! - return - end - self.rounds.destroy_all! wcif["rounds"].each_with_index do |wcif_round, index| self.rounds.create!(Round.wcif_to_round_attributes(wcif_round, index+1)) diff --git a/WcaOnRails/spec/requests/competitions_spec.rb b/WcaOnRails/spec/requests/competitions_spec.rb index 12ec26e8f3..752caf8752 100644 --- a/WcaOnRails/spec/requests/competitions_spec.rb +++ b/WcaOnRails/spec/requests/competitions_spec.rb @@ -108,6 +108,128 @@ end end + context 'when signed in as competition delegate' do + let(:comp_delegate) { competition.delegates.first } + + before :each do + sign_in comp_delegate + competition.events = [Event.find("333"), Event.find("222")] + competition.update!(isConfirmed: true) + end + + it 'allows adding rounds to an event of confirmed competition' do + headers = { "CONTENT_TYPE" => "application/json" } + competition_events = [ + { + id: "333", + rounds: [ + { + id: "333-1", + format: "a", + timeLimit: { + centiseconds: 4242, + cumulativeRoundIds: [], + }, + cutoff: nil, + advancementCondition: nil, + }, + ], + }, + { + id: "222", + rounds: [ + { + id: "222-1", + format: "a", + timeLimit: nil, + cutoff: nil, + advancementCondition: nil, + }, + ], + }, + ] + expect(competition.reload.competition_events.find_by_event_id("333").rounds.length).to eq 0 + patch update_events_from_wcif_path(competition), params: competition_events.to_json, headers: headers + expect(response).to be_success + expect(competition.reload.competition_events.find_by_event_id("333").rounds.length).to eq 1 + end + + it 'does not allow adding events to a confirmed competition' do + headers = { "CONTENT_TYPE" => "application/json" } + competition_events = [ + { + id: "333", + rounds: [ + { + id: "333-1", + format: "a", + timeLimit: nil, + cutoff: nil, + advancementCondition: nil, + }, + ], + }, + { + id: "222", + rounds: [ + { + id: "222-1", + format: "a", + timeLimit: nil, + cutoff: nil, + advancementCondition: nil, + }, + ], + }, + { + id: "333oh", + rounds: [ + { + id: "333oh-1", + format: "a", + timeLimit: nil, + cutoff: nil, + advancementCondition: nil, + }, + ], + }, + ] + patch update_events_from_wcif_path(competition), params: competition_events.to_json, headers: headers + expect(response).to have_http_status(422) + response_json = JSON.parse(response.body) + expect(response_json["error"]).to eq "Cannot add events to a confirmed competition" + end + + it 'does not allow removing events from a confirmed competition' do + headers = { "CONTENT_TYPE" => "application/json" } + competition_events = [ + { + id: "333", + rounds: [ + { + id: "333-1", + format: "a", + timeLimit: { + centiseconds: 4242, + cumulativeRoundIds: [], + }, + cutoff: nil, + advancementCondition: nil, + }, + ], + }, + { + id: "222", + rounds: [], + }, + ] + patch update_events_from_wcif_path(competition), params: competition_events.to_json, headers: headers + expect(response).to have_http_status(422) + response_json = JSON.parse(response.body) + expect(response_json["error"]).to eq "Cannot remove events from a confirmed competition" + end + end + context 'when signed in as a regular user' do sign_in { FactoryGirl.create :user } From 5ea98d6cafa497290935846686d059f8764be666 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Thu, 7 Sep 2017 14:09:58 -0700 Subject: [PATCH 34/42] Update UI to reflect that events cannot be added or removed to confirmed competitions. --- .../app/javascript/edit-events/EditEvents.jsx | 33 +++++++++++++++---- .../app/javascript/edit-events/index.jsx | 5 +-- .../views/competitions/edit_events.html.erb | 1 + 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index 553820fabc..a9d1e41af9 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -59,7 +59,7 @@ export default class EditEvents extends React.Component { } render() { - let { competitionId, wcifEvents } = this.props; + let { competitionId, competitionConfirmed, wcifEvents } = this.props; let unsavedChanges = null; if(this.unsavedChanges()) { unsavedChanges = ( @@ -81,7 +81,7 @@ export default class EditEvents extends React.Component { {wcifEvents.map(wcifEvent => { return ( <div key={wcifEvent.id} className="col-xs-12 col-sm-12 col-md-6 col-lg-4"> - <EventPanel wcifEvents={wcifEvents} wcifEvent={wcifEvent} /> + <EventPanel wcifEvents={wcifEvents} wcifEvent={wcifEvent} competitionConfirmed={competitionConfirmed} /> </div> ); })} @@ -153,7 +153,7 @@ function RoundsTable({ wcifEvents, wcifEvent }) { ); } -const EventPanel = ({ wcifEvents, wcifEvent }) => { +const EventPanel = ({ wcifEvents, competitionConfirmed, wcifEvent }) => { let event = events.byId[wcifEvent.id]; let roundCountChanged = e => { let newRoundCount = parseInt(e.target.value); @@ -175,16 +175,35 @@ const EventPanel = ({ wcifEvents, wcifEvent }) => { rootRender(); }; + let panelTitle = null; + let disableAdd = false; + let disableRemove = false; + if(competitionConfirmed) { + if(wcifEvent.rounds.length === 0) { + disableAdd = true; + panelTitle = `Cannot add ${wcifEvent.id} because the competition is confirmed.`; + } else { + disableRemove = true; + panelTitle = `Cannot remove ${wcifEvent.id} because the competition is confirmed.`; + } + } + return ( <div className={cn(`panel panel-default event-${wcifEvent.id}`, { 'event-not-being-held': wcifEvent.rounds.length == 0 })}> - <div className="panel-heading"> + <div className="panel-heading" title={panelTitle}> <h3 className="panel-title"> <span className={cn("img-thumbnail", "cubing-icon", `event-${event.id}`)}></span> <span className="title">{event.name}</span> {" "} - <select className="form-control input-xs" name="select-round-count" value={wcifEvent.rounds.length} onChange={roundCountChanged}> - <option value={0}>Not being held</option> - <option disabled="disabled">────────</option> + <select + className="form-control input-xs" + name="select-round-count" + value={wcifEvent.rounds.length} + onChange={roundCountChanged} + disabled={disableAdd} + > + {!disableRemove && <option value={0}>Not being held</option>} + {!disableRemove && <option disabled="disabled">────────</option>} <option value={1}>1 round</option> <option value={2}>2 rounds</option> <option value={3}>3 rounds</option> diff --git a/WcaOnRails/app/javascript/edit-events/index.jsx b/WcaOnRails/app/javascript/edit-events/index.jsx index 5f138e1111..a8962f0ac1 100644 --- a/WcaOnRails/app/javascript/edit-events/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/index.jsx @@ -26,7 +26,7 @@ export function promiseSaveWcif(wcif) { let state = {}; export function rootRender() { ReactDOM.render( - <EditEvents competitionId={state.competitionId} wcifEvents={state.wcifEvents} />, + <EditEvents competitionId={state.competitionId} competitionConfirmed={state.competitionConfirmed} wcifEvents={state.wcifEvents} />, document.getElementById('events-edit-area'), ) } @@ -37,8 +37,9 @@ function normalizeWcifEvents(wcifEvents) { }); } -wca.initializeEventsForm = (competitionId, wcifEvents) => { +wca.initializeEventsForm = (competitionId, competitionConfirmed, wcifEvents) => { state.competitionId = competitionId; + state.competitionConfirmed = competitionConfirmed; state.wcifEvents = normalizeWcifEvents(wcifEvents); rootRender(); } diff --git a/WcaOnRails/app/views/competitions/edit_events.html.erb b/WcaOnRails/app/views/competitions/edit_events.html.erb index f1d5c28444..417be8a92b 100644 --- a/WcaOnRails/app/views/competitions/edit_events.html.erb +++ b/WcaOnRails/app/views/competitions/edit_events.html.erb @@ -10,6 +10,7 @@ $(function() { wca.initializeEventsForm( <%= @competition.id.to_json.html_safe %>, + <%= @competition.isConfirmed.to_json.html_safe %>, <%= @competition.competition_events.map(&:to_wcif).to_json.html_safe %> ); }); From a7691d1def0c091d36a2505747353b043fb8b184 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Sat, 9 Sep 2017 02:04:43 -0700 Subject: [PATCH 35/42] Less confusing modal behavior. --- .../edit-events/ButtonActivatedModal.jsx | 53 ++++++++++++++++--- .../javascript/edit-events/modals/index.jsx | 9 +++- .../spec/features/competition_events_spec.rb | 6 +-- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx index e0891911d0..5dabbcae34 100644 --- a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx +++ b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx @@ -2,6 +2,8 @@ import React from 'react' import cn from 'classnames' import Modal from 'react-bootstrap/lib/Modal' import Button from 'react-bootstrap/lib/Button' +import addEventListener from 'react-overlays/lib/utils/addEventListener'; +import ownerDocument from 'react-overlays/lib/utils/ownerDocument'; export default class extends React.Component { constructor() { @@ -14,6 +16,10 @@ export default class extends React.Component { } close = () => { + if(this.props.hasUnsavedChanges() && !confirm("Are you sure you want to discard your changes?")) { + return; + } + this.props.reset(); this.setState({ showModal: false }); } @@ -22,17 +28,52 @@ export default class extends React.Component { return ( <button type="button" name={this.props.name} className={cn("btn", this.props.buttonClass)} onClick={this.open}> {this.props.buttonValue} - <Modal show={this.state.showModal} onHide={this.close} backdrop="static"> - <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onSave(); }}> + <KeydownDismissModal show={this.state.showModal} onHide={this.close}> + <form className={this.props.formClass} onSubmit={e => { e.preventDefault(); this.props.onOk(); }}> {this.props.children} <Modal.Footer> - <Button onClick={this.close} className="pull-left">Close</Button> - <Button onClick={this.props.reset} bsStyle="danger" className="pull-left">Reset</Button> - <Button type="submit" bsStyle="primary">Save</Button> + <Button onClick={this.close} bsStyle="warning">Close</Button> + <Button type="submit" bsStyle="success">Ok</Button> </Modal.Footer> </form> - </Modal> + </KeydownDismissModal> </button> ); } } + +// More or less copied from https://github.com/react-bootstrap/react-overlays/pull/195 +// This can go away once a new version of react-overlays is released and react-bootstrap +// is updated to depend on it. +class KeydownDismissModal extends React.Component { + static defaultProps = Modal.defaultProps; + + handleDocumentKeyDown = (e) => { + if (this.props.keyboard && e.key === 'Escape' && this._modal._modal.isTopModal()) { + if (this.props.onEscapeKeyDown) { + this.props.onEscapeKeyDown(e); + } + + this.props.onHide(); + } + } + + onShow = () => { + let doc = ownerDocument(this); + this._onDocumentKeydownListener = + addEventListener(doc, 'keydown', this.handleDocumentKeyDown); + } + + onHide = () => { + this._onDocumentKeydownListener.remove(); + } + + render() { + let subprops = { + ...this.props, + keyboard: false, + onShow: this.onShow, + }; + return <Modal {...subprops} ref={m => this._modal = m} />; + } +} diff --git a/WcaOnRails/app/javascript/edit-events/modals/index.jsx b/WcaOnRails/app/javascript/edit-events/modals/index.jsx index bded57620b..164b074d7e 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/index.jsx @@ -65,11 +65,15 @@ class EditRoundAttribute extends React.Component { return this.getWcifRound()[this.props.attribute]; } + hasUnsavedChanges = () => { + return JSON.stringify(this.getSavedValue()) != JSON.stringify(this.state.value); + } + onChange = (value) => { this.setState({ value: value }); } - onSave = () => { + onOk = () => { let wcifRound = this.getWcifRound(); wcifRound[this.props.attribute] = this.state.value; @@ -118,8 +122,9 @@ class EditRoundAttribute extends React.Component { name={attribute} buttonClass="btn-default btn-xs" formClass="form-horizontal" - onSave={this.onSave} + onOk={this.onOk} reset={this.reset} + hasUnsavedChanges={this.hasUnsavedChanges} ref={c => this._modal = c} > <Modal.Header closeButton> diff --git a/WcaOnRails/spec/features/competition_events_spec.rb b/WcaOnRails/spec/features/competition_events_spec.rb index 9c2b9a47d3..382274ba58 100644 --- a/WcaOnRails/spec/features/competition_events_spec.rb +++ b/WcaOnRails/spec/features/competition_events_spec.rb @@ -38,7 +38,7 @@ within_modal do fill_in "minutes", with: "5" - click_button "Save" + click_button "Ok" end save @@ -52,7 +52,7 @@ within_modal do select "Best of 2", from: "Round format" fill_in "minutes", with: "2" - click_button "Save" + click_button "Ok" end save @@ -68,7 +68,7 @@ within_modal do select "Ranking", from: "Type" fill_in "Ranking", with: "12" - click_button "Save" + click_button "Ok" end save From dbde71eababda09bb2a782cae300e53de0eddfd8 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Sat, 9 Sep 2017 16:20:28 -0700 Subject: [PATCH 36/42] Update submodal to have the new ok, cancel behavior for all our modals. --- .../javascript/edit-events/ButtonActivatedModal.jsx | 4 ++-- .../app/javascript/edit-events/modals/TimeLimit.jsx | 11 ++++++++--- .../app/javascript/edit-events/modals/index.jsx | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx index 5dabbcae34..b584b3d142 100644 --- a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx +++ b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx @@ -15,8 +15,8 @@ export default class extends React.Component { this.setState({ showModal: true }); } - close = () => { - if(this.props.hasUnsavedChanges() && !confirm("Are you sure you want to discard your changes?")) { + close = ({ skipUnsavedChangesCheck } = { skipUnsavedChangesCheck: false }) => { + if(!skipUnsavedChangesCheck && this.props.hasUnsavedChanges() && !confirm("Are you sure you want to discard your changes?")) { return; } diff --git a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx index 227ca31ebc..a5f95896b8 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx @@ -56,9 +56,13 @@ class SelectRoundsButton extends React.Component { return Object.entries(this.state.selectedRoundsById).filter(([k, v]) => v).map(([k, v]) => k); } - onSave = () => { + onOk = () => { this.props.onChange(); - this._modal.close(); + this._modal.close({ skipUnsavedChangesCheck: true }); + } + + hasUnsavedChanges = () => { + return JSON.stringify(this.props.selectedRoundIds) != JSON.stringify(this.getSelectedRoundIds()); } render() { @@ -83,7 +87,8 @@ class SelectRoundsButton extends React.Component { <ButtonActivatedModal buttonValue="Share with other rounds" buttonClass="btn-success" - onSave={this.onSave} + onOk={this.onOk} + hasUnsavedChanges={this.hasUnsavedChanges} reset={this.reset} ref={c => this._modal = c} > diff --git a/WcaOnRails/app/javascript/edit-events/modals/index.jsx b/WcaOnRails/app/javascript/edit-events/modals/index.jsx index 164b074d7e..8a92c52340 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/index.jsx @@ -101,7 +101,7 @@ class EditRoundAttribute extends React.Component { } } - this._modal.close(); + this._modal.close({ skipUnsavedChangesCheck: true }); rootRender(); } From a82ae46d37168ba521718c9fc71db3e49068ca51 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Sun, 10 Sep 2017 14:10:10 -0700 Subject: [PATCH 37/42] Responding to @jonatanklosko's review comments. --- .../edit-events/ButtonActivatedModal.jsx | 10 ++-- .../app/javascript/edit-events/EditEvents.jsx | 49 +++++++----------- .../app/javascript/edit-events/index.jsx | 2 +- .../modals/AdvancementCondition.jsx | 39 +++++---------- .../edit-events/modals/AttemptResultInput.jsx | 19 ++++--- .../javascript/edit-events/modals/Cutoff.jsx | 41 ++++++++------- .../edit-events/modals/TimeLimit.jsx | 40 ++++++--------- .../javascript/edit-events/modals/index.jsx | 40 ++++----------- .../javascript/edit-events/modals/utils.js | 21 +++++--- WcaOnRails/app/javascript/polyfills/index.js | 50 +------------------ WcaOnRails/app/javascript/wca/events.js.erb | 16 ++---- WcaOnRails/app/javascript/wca/formats.js.erb | 8 ++- WcaOnRails/app/models/event.rb | 6 +-- WcaOnRails/lib/solve_time.rb | 8 +++ WcaOnRails/package.json | 1 - .../spec/features/competition_events_spec.rb | 13 ++--- WcaOnRails/spec/models/round_spec.rb | 12 +---- WcaOnRails/yarn.lock | 4 -- 18 files changed, 142 insertions(+), 237 deletions(-) diff --git a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx index b584b3d142..5c944677b5 100644 --- a/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx +++ b/WcaOnRails/app/javascript/edit-events/ButtonActivatedModal.jsx @@ -5,7 +5,7 @@ import Button from 'react-bootstrap/lib/Button' import addEventListener from 'react-overlays/lib/utils/addEventListener'; import ownerDocument from 'react-overlays/lib/utils/ownerDocument'; -export default class extends React.Component { +export default class ButtonActivatedModal extends React.Component { constructor() { super(); this.state = { showModal: false }; @@ -16,12 +16,10 @@ export default class extends React.Component { } close = ({ skipUnsavedChangesCheck } = { skipUnsavedChangesCheck: false }) => { - if(!skipUnsavedChangesCheck && this.props.hasUnsavedChanges() && !confirm("Are you sure you want to discard your changes?")) { - return; + if(skipUnsavedChangesCheck || !this.props.hasUnsavedChanges() || confirm("Are you sure you want to discard your changes?")) { + this.props.reset(); + this.setState({ showModal: false }); } - - this.props.reset(); - this.setState({ showModal: false }); } render() { diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index a9d1e41af9..d306c56edb 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -7,13 +7,7 @@ import { rootRender, promiseSaveWcif } from 'edit-events' import { EditTimeLimitButton, EditCutoffButton, EditAdvancementConditionButton } from 'edit-events/modals' export default class EditEvents extends React.Component { - constructor(props) { - super(props); - this.save = this.save.bind(this); - this.onUnload = this.onUnload.bind(this); - } - - save(e) { + save = e => { let {competitionId, wcifEvents} = this.props; let wcif = { id: competitionId, @@ -22,32 +16,33 @@ export default class EditEvents extends React.Component { this.setState({ saving: true }); promiseSaveWcif(wcif).then(response => { - return response.json().then(json => [ response, json ]); + return Promise.all([response, response.json()]); }).then(([response, json]) => { if(!response.ok) { throw new Error(`${response.status}: ${response.statusText}\n${json["error"]}`); } - this.setState({ savedWcifEvents: clone(this.props.wcifEvents), saving: false }); + this.setState({ savedWcifEvents: _.cloneDeep(wcifEvents), saving: false }); }).catch(e => { this.setState({ saving: false }); - alert("Something went wrong while saving.\n" + e.message); + alert(`Something went wrong while saving.\n${e.message}`); }); } unsavedChanges() { - return !deepEqual(this.state.savedWcifEvents, this.props.wcifEvents); + return !_.isEqual(this.state.savedWcifEvents, this.props.wcifEvents); } - onUnload(e) { + onUnload = e => { + // Prompt the user before letting them navigate away from this page with unsaved changes. if(this.unsavedChanges()) { - var confirmationMessage = "\o/"; + let confirmationMessage = "You have unsaved changes, are you sure you want to leave?"; e.returnValue = confirmationMessage; return confirmationMessage; } } componentWillMount() { - this.setState({ savedWcifEvents: clone(this.props.wcifEvents) }); + this.setState({ savedWcifEvents: _.cloneDeep(this.props.wcifEvents) }); } componentDidMount() { @@ -94,7 +89,6 @@ export default class EditEvents extends React.Component { function RoundsTable({ wcifEvents, wcifEvent }) { let event = events.byId[wcifEvent.id]; - let canChangeTimeLimit = event.can_change_time_limit; return ( <div className="table-responsive"> <table className="table table-condensed"> @@ -102,7 +96,7 @@ function RoundsTable({ wcifEvents, wcifEvent }) { <tr> <th>#</th> <th className="text-center">Format</th> - {canChangeTimeLimit && <th className="text-center">Time Limit</th>} + {event.canChangeTimeLimit && <th className="text-center">Time Limit</th>} <th className="text-center">Cutoff</th> <th className="text-center">To Advance</th> </tr> @@ -110,7 +104,7 @@ function RoundsTable({ wcifEvents, wcifEvent }) { <tbody> {wcifEvent.rounds.map((wcifRound, index) => { let roundNumber = index + 1; - let isLastRound = roundNumber == wcifEvent.rounds.length; + let isLastRound = roundNumber === wcifEvent.rounds.length; let roundFormatChanged = e => { let newFormat = e.target.value; @@ -131,7 +125,7 @@ function RoundsTable({ wcifEvents, wcifEvent }) { </select> </td> - {canChangeTimeLimit && ( + {event.canChangeTimeLimit && ( <td className="text-center"> <EditTimeLimitButton wcifEvents={wcifEvents} wcifEvent={wcifEvent} roundNumber={roundNumber} /> </td> @@ -153,18 +147,17 @@ function RoundsTable({ wcifEvents, wcifEvent }) { ); } -const EventPanel = ({ wcifEvents, competitionConfirmed, wcifEvent }) => { +function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { let event = events.byId[wcifEvent.id]; let roundCountChanged = e => { let newRoundCount = parseInt(e.target.value); if(wcifEvent.rounds.length > newRoundCount) { // We have too many rounds, remove the extras. - wcifEvent.rounds = wcifEvent.rounds.slice(0, newRoundCount); + wcifEvent.rounds = _.take(wcifEvent.rounds, newRoundCount); // Final rounds must not have an advance to next round requirement. if(wcifEvent.rounds.length >= 1) { - let lastRound = wcifEvent.rounds[wcifEvent.rounds.length - 1]; - lastRound.advancementCondition = null; + _.last(wcifEvent.rounds).advancementCondition = null; } } else { // We do not have enough rounds, create the missing ones. @@ -219,7 +212,7 @@ const EventPanel = ({ wcifEvents, competitionConfirmed, wcifEvent }) => { )} </div> ); -}; +} function addRoundToEvent(wcifEvent) { const DEFAULT_TIME_LIMIT = { centiseconds: 10*60*100, cumulativeRoundIds: [] }; @@ -227,7 +220,7 @@ function addRoundToEvent(wcifEvent) { let nextRoundNumber = wcifEvent.rounds.length + 1; wcifEvent.rounds.push({ id: `${wcifEvent.id}-${nextRoundNumber}`, - format: event.recommended_format().id, + format: event.recommentedFormat().id, timeLimit: DEFAULT_TIME_LIMIT, cutoff: null, advancementCondition: null, @@ -235,11 +228,3 @@ function addRoundToEvent(wcifEvent) { groups: [], }); } - -function clone(obj) { - return JSON.parse(JSON.stringify(obj)); -} - -function deepEqual(obj1, obj2) { - return JSON.stringify(obj1) == JSON.stringify(obj2); -} diff --git a/WcaOnRails/app/javascript/edit-events/index.jsx b/WcaOnRails/app/javascript/edit-events/index.jsx index a8962f0ac1..a9248d0515 100644 --- a/WcaOnRails/app/javascript/edit-events/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/index.jsx @@ -33,7 +33,7 @@ export function rootRender() { function normalizeWcifEvents(wcifEvents) { return events.official.map(event => { - return wcifEvents.find(wcifEvent => wcifEvent.id == event.id) || { id: event.id, rounds: [] }; + return _.find(wcifEvents, { id: event.id }) || { id: event.id, rounds: [] }; }); } diff --git a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx index 47249655fd..f4537f0870 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AdvancementCondition.jsx @@ -35,32 +35,17 @@ export default { return <span>{str}</span>; }, Input({ value: advancementCondition, onChange, autoFocus, roundNumber, wcifEvent }) { - let typeInput, rankingInput, percentInput, attemptResultInput; + let typeInput; + let inputByType = {}; let onChangeAggregator = () => { let type = typeInput.value; - let newAdvancementCondition; - switch(typeInput.value) { - case "ranking": - newAdvancementCondition = { - type: "ranking", - level: rankingInput ? parseInt(rankingInput.value): 0, - }; - break; - case "percent": - newAdvancementCondition = { - type: "percent", - level: percentInput ? parseInt(percentInput.value) : 0, - }; - break; - case "attemptResult": - newAdvancementCondition = { - type: "attemptResult", - level: attemptResultInput ? parseInt(attemptResultInput.value) : 0, - }; - break; - default: - newAdvancementCondition = null; - break; + let newAdvancementCondition = null; + if(type !== "") { + let input = inputByType[type]; + newAdvancementCondition = { + type, + level: input ? parseInt(input.value) : 0, + }; } onChange(newAdvancementCondition); }; @@ -72,7 +57,7 @@ export default { switch(advancementType) { case "ranking": valueLabel = "Ranking"; - advancementInput = <input type="number" id="advacement-condition-value" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => rankingInput = c} />; + advancementInput = <input type="number" id="advacement-condition-value" className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} ref={c => inputByType["ranking"] = c} />; helpBlock = `The top ${advancementCondition.level} competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; break; case "percent": @@ -85,14 +70,14 @@ export default { className="form-control" value={advancementCondition.level} onChange={onChangeAggregator} - ref={c => percentInput = c} + ref={c => inputByType["percent"] = c} /> ); helpBlock = `The top ${advancementCondition.level}% of competitors from round ${roundNumber} will advance to round ${roundNumber + 1}.`; break; case "attemptResult": valueLabel = "Result"; - advancementInput = <AttemptResultInput id="advacement-condition-value" eventId={wcifEvent.id} value={advancementCondition.level} onChange={onChangeAggregator} ref={c => attemptResultInput = c} />; + advancementInput = <AttemptResultInput id="advacement-condition-value" eventId={wcifEvent.id} value={advancementCondition.level} onChange={onChangeAggregator} ref={c => inputByType["attemptResult"] = c} />; helpBlock = `Everyone in round ${roundNumber} with a result ${matchResult(advancementCondition.level, wcifEvent.id)} will advance to round ${roundNumber + 1}.`; break; default: diff --git a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx index e28bfa9c0c..45de13a8e2 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/AttemptResultInput.jsx @@ -8,6 +8,9 @@ import { attemptResultToMbPoints, } from './utils' +// https://www.worldcubeassociation.org/regulations/#E2d1 +const MAX_FMC_SOLUTION_LENGTH = 80; + class CentisecondsInput extends React.Component { get value() { let minutes = parseInt(this.minutesInput.value) || 0; @@ -60,7 +63,7 @@ class CentisecondsInput extends React.Component { } } -export default class extends React.Component { +export default class AttemptResultInput extends React.Component { onChange = () => { this.props.onChange(); } @@ -68,11 +71,11 @@ export default class extends React.Component { get value() { let event = events.byId[this.props.eventId]; - if(event.timed_event) { + if(event.isTimedEvent) { return this.centisecondsInput.value; - } else if(event.fewest_moves) { + } else if(event.isFewestMoves) { return parseInt(this.movesInput.value); - } else if(event.multiple_blindfolded) { + } else if(event.isMultipleBlindfolded) { return mbPointsToAttemptResult(parseInt(this.mbldPointsInput.value)); } else { throw new Error(`Unrecognized event type: ${event.id}`); @@ -83,17 +86,18 @@ export default class extends React.Component { let { id, autoFocus } = this.props; let event = events.byId[this.props.eventId]; - if(event.timed_event) { + if(event.isTimedEvent) { return <CentisecondsInput id={id} autoFocus={autoFocus} centiseconds={this.props.value} onChange={this.onChange} ref={c => this.centisecondsInput = c} />; - } else if(event.fewest_moves) { + } else if(event.isFewestMoves) { return ( <div> <input type="number" + min={1} max={MAX_FMC_SOLUTION_LENGTH} id={id} className="form-control" autoFocus={autoFocus} @@ -103,10 +107,11 @@ export default class extends React.Component { moves </div> ); - } else if(event.multiple_blindfolded) { + } else if(event.isMultipleBlindfolded) { return ( <div> <input type="number" + min={1} id={id} className="form-control" autoFocus={autoFocus} diff --git a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx index ccc2e2533a..7fc5099cc7 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/Cutoff.jsx @@ -6,22 +6,34 @@ import AttemptResultInput from './AttemptResultInput' import { pluralize, matchResult, + parseRoundId, roundIdToString, attemptResultToString, } from './utils' +function roundCutoffToString(wcifRound, { short } = {}) { + let cutoff = wcifRound.cutoff; + if(!cutoff) { + return "-"; + } + + let eventId = parseRoundId(wcifRound.id).eventId; + let matchStr = matchResult(cutoff.attemptResult, eventId, { short }); + if(short) { + return `Best of ${cutoff.numberOfAttempts} ${matchStr}`; + } else { + let explanationText = `Competitors get ${pluralize(cutoff.numberOfAttempts, "attempt")} to get ${matchStr}.`; + explanationText += ` If they succeed, they get to do all ${formats.byId[wcifRound.format].expectedSolveCount} solves.`; + return explanationText; + } +} + export default { Title({ wcifRound }) { return <span>Cutoff for {roundIdToString(wcifRound.id)}</span>; }, - Show({ value: cutoff, wcifEvent }) { - let str; - if(cutoff) { - str = `Best of ${cutoff.numberOfAttempts} ${matchResult(cutoff.attemptResult, wcifEvent.id, { short: true })}`; - } else { - str = "-"; - } - return <span>{str}</span>; + Show({ value: cutoff, wcifEvent, wcifRound }) { + return <span>{roundCutoffToString(wcifRound, { short: true })}</span>; }, Input({ value: cutoff, onChange, autoFocus, wcifEvent, roundNumber }) { let wcifRound = wcifEvent.rounds[roundNumber - 1]; @@ -30,23 +42,18 @@ export default { let onChangeAggregator = () => { let numberOfAttempts = parseInt(numberOfAttemptsInput.value); let newCutoff; - if(numberOfAttempts > 0) { + if(numberOfAttempts === 0) { + newCutoff = null; + } else { newCutoff = { numberOfAttempts, attemptResult: attemptResultInput ? parseInt(attemptResultInput.value) : 0, }; - } else { - newCutoff = null; } onChange(newCutoff); }; - let explanationText = null; - if(cutoff) { - explanationText = `Competitors get ${pluralize(cutoff.numberOfAttempts, "attempt")} to get ${matchResult(cutoff.attemptResult, wcifEvent.id)}.`; - explanationText += ` If they succeed, they get to do all ${formats.byId[wcifRound.format].expected_solve_count} solves.`; - } - + let explanationText = cutoff ? roundCutoffToString(wcifRound) : null; return ( <div> <div className="form-group"> diff --git a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx index a5f95896b8..61d2df2f96 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx @@ -7,7 +7,7 @@ import Radio from 'react-bootstrap/lib/Radio' import events from 'wca/events.js.erb' import formats from 'wca/formats.js.erb' import AttemptResultInput from './AttemptResultInput' -import { centisecondsToString, roundIdToString } from './utils' +import { centisecondsToString, roundIdToString, parseRoundId } from './utils' import ButtonActivatedModal from 'edit-events/ButtonActivatedModal' class RadioGroup extends React.Component { @@ -33,9 +33,7 @@ class RadioGroup extends React.Component { } function objectifyArray(arr) { - let obj = {}; - arr.forEach(el => obj[el] = true); - return obj; + return _.fromPairs(arr.map(el => [el, true])); } class SelectRoundsButton extends React.Component { @@ -53,7 +51,7 @@ class SelectRoundsButton extends React.Component { } getSelectedRoundIds() { - return Object.entries(this.state.selectedRoundsById).filter(([k, v]) => v).map(([k, v]) => k); + return _.keys(_.pickBy(this.state.selectedRoundsById)); } onOk = () => { @@ -62,25 +60,22 @@ class SelectRoundsButton extends React.Component { } hasUnsavedChanges = () => { - return JSON.stringify(this.props.selectedRoundIds) != JSON.stringify(this.getSelectedRoundIds()); + return !_.isEqual(this.props.selectedRoundIds, this.getSelectedRoundIds()); } render() { - let { timeLimit, excludeRound, wcifEvents } = this.props; + let { timeLimit, excludeEventId, wcifEvents } = this.props; let selectedRoundsById = this.state.selectedRoundsById; - let wcifRounds = []; - wcifEvents.forEach(otherWcifEvent => { + let wcifRounds = _.flatMap(wcifEvents, otherWcifEvent => { // Cross round cumulative time limits may not include other rounds of // the same event. // See https://github.com/thewca/wca-regulations/issues/457. - let excludeEventId = excludeRound.id.split("-")[0]; let otherEvent = events.byId[otherWcifEvent.id]; - let canChangeTimeLimit = otherEvent.can_change_time_limit; - if(!canChangeTimeLimit || excludeEventId == otherWcifEvent.id) { - return; + if(!otherEvent.canChangeTimeLimit || excludeEventId === otherWcifEvent.id) { + return []; } - wcifRounds = wcifRounds.concat(otherWcifEvent.rounds.filter(r => r != excludeRound)); + return otherWcifEvent.rounds; }); return ( @@ -101,10 +96,10 @@ class SelectRoundsButton extends React.Component { <ul className="list-unstyled"> {wcifRounds.map(wcifRound => { let roundId = wcifRound.id; - let eventId = roundId.split("-")[0]; + let { eventId } = parseRoundId(roundId); let event = events.byId[eventId]; let checked = !!selectedRoundsById[roundId]; - let eventAlreadySelected = this.getSelectedRoundIds().find(roundId => roundId.split("-")[0] == eventId); + let eventAlreadySelected = this.getSelectedRoundIds().find(roundId => parseRoundId(roundId).eventId === eventId); let disabled = !checked && eventAlreadySelected; let disabledReason = disabled && `Cannot select this round because you've already selected a round with ${event.name}`; return ( @@ -188,10 +183,7 @@ export default { cumulativeRoundIds = []; break; case "cumulative": - cumulativeRoundIds = [wcifRound.id]; - if(roundsSelector) { - cumulativeRoundIds = _.uniq(cumulativeRoundIds.concat(roundsSelector.getSelectedRoundIds())); - } + cumulativeRoundIds = roundsSelector ? roundsSelector.getSelectedRoundIds() : [wcifRound.id]; break; default: throw new Error(`Unrecognized value ${cumulativeRadio.value}`); @@ -208,7 +200,7 @@ export default { let selectRoundsButton = ( <SelectRoundsButton onChange={onChangeAggregator} wcifEvents={wcifEvents} - excludeRound={wcifRound} + excludeEventId={event.id} selectedRoundIds={timeLimit.cumulativeRoundIds} ref={c => roundsSelector = c} /> @@ -227,12 +219,12 @@ export default { <div>{selectRoundsButton}</div> </span>); } else { - let otherSelectedRoundIds = timeLimit.cumulativeRoundIds.filter(roundId => roundId != wcifRound.id); + let otherSelectedRoundIds = _.without(timeLimit.cumulativeRoundIds, wcifRound.id); description = (<span> This round has a cross round cumulative time limit (see <GuidelineLink guideline="A1a2++" />). This means that competitors have {centisecondsToString(timeLimit.centiseconds)} total for all - of their solves in this round ({wcifRound.id}) shared with: + of their solves in this round ({roundIdToString(wcifRound.id)}) shared with: <ul> {otherSelectedRoundIds.map(roundId => <li key={roundId}>{roundIdToString(roundId)}</li>)} </ul> @@ -257,7 +249,7 @@ export default { <div className="form-group"> <div className="col-sm-offset-2 col-sm-10"> - <RadioGroup value={timeLimit.cumulativeRoundIds.length == 0 ? "per-solve" : "cumulative"} + <RadioGroup value={timeLimit.cumulativeRoundIds.length === 0 ? "per-solve" : "cumulative"} name="cumulative-radio" onChange={onChangeAggregator} ref={c => cumulativeRadio = c} diff --git a/WcaOnRails/app/javascript/edit-events/modals/index.jsx b/WcaOnRails/app/javascript/edit-events/modals/index.jsx index 8a92c52340..4a4b4a8d61 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/index.jsx @@ -20,31 +20,15 @@ let RoundAttributeComponents = { }; function findRoundsSharingTimeLimitWithRound(wcifEvents, wcifRound) { - let roundsSharingTimeLimit = []; - wcifEvents.forEach(otherWcifEvent => { - otherWcifEvent.rounds.forEach(otherWcifRound => { - if(otherWcifRound == wcifRound || !otherWcifRound.timeLimit) { - return; - } - - if(otherWcifRound.timeLimit.cumulativeRoundIds.indexOf(wcifRound.id) >= 0) { - roundsSharingTimeLimit.push(otherWcifRound); - } - }); - }); - return roundsSharingTimeLimit; + return _.flatMap(wcifEvents, 'rounds').filter(otherWcifRound => + otherWcifRound !== wcifRound + && otherWcifRound.timeLimit + && otherWcifRound.timeLimit.cumulativeRoundIds.includes(wcifRound.id) + ); } function findRounds(wcifEvents, roundIds) { - let wcifRounds = []; - wcifEvents.forEach(wcifEvent => { - wcifEvent.rounds.forEach(wcifRound => { - if(roundIds.indexOf(wcifRound.id) >= 0) { - wcifRounds.push(wcifRound); - } - }); - }); - return wcifRounds; + return _.flatMap(wcifEvents, 'rounds').filter(wcifRound => roundIds.includes(wcifRound.id)); } class EditRoundAttribute extends React.Component { @@ -66,11 +50,11 @@ class EditRoundAttribute extends React.Component { } hasUnsavedChanges = () => { - return JSON.stringify(this.getSavedValue()) != JSON.stringify(this.state.value); + return !_.isEqual(this.getSavedValue(), this.state.value); } onChange = (value) => { - this.setState({ value: value }); + this.setState({ value }); } onOk = () => { @@ -86,11 +70,7 @@ class EditRoundAttribute extends React.Component { // First, remove this round from all other rounds that previously shared // a time limit with this round. findRoundsSharingTimeLimitWithRound(this.props.wcifEvents, wcifRound).forEach(otherWcifRound => { - let index = otherWcifRound.timeLimit.cumulativeRoundIds.indexOf(wcifRound.id); - if(index < 0) { - throw new Error(); - } - otherWcifRound.timeLimit.cumulativeRoundIds.splice(index, 1); + _.pull(otherWcifRound.timeLimit.cumulativeRoundIds, wcifRound.id); }); // Second, clobber the time limits for all rounds that this round now shares a time limit with. @@ -118,7 +98,7 @@ class EditRoundAttribute extends React.Component { return ( <ButtonActivatedModal - buttonValue={<Show value={this.getSavedValue()} wcifEvent={wcifEvent} />} + buttonValue={<Show value={this.getSavedValue()} wcifRound={wcifRound} wcifEvent={wcifEvent} />} name={attribute} buttonClass="btn-default btn-xs" formClass="form-horizontal" diff --git a/WcaOnRails/app/javascript/edit-events/modals/utils.js b/WcaOnRails/app/javascript/edit-events/modals/utils.js index 046a3c9a78..b71e33b09e 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/utils.js +++ b/WcaOnRails/app/javascript/edit-events/modals/utils.js @@ -1,5 +1,6 @@ import events from 'wca/events.js.erb' +// This is ported from the Ruby code in solve_time.rb. function parseMbValue(mbValue) { let old = Math.floor(mbValue / 1000000000) !== 0; let timeSeconds, attempted, solved; @@ -24,6 +25,7 @@ function parseMbValue(mbValue) { return { solved, attempted, timeCentiseconds }; } +// This is ported from the Ruby code in solve_time.rb. function parsedMbToAttemptResult(parsedMb) { let { solved, attempted, timeCentiseconds } = parsedMb; let missed = attempted - solved; @@ -34,6 +36,7 @@ function parsedMbToAttemptResult(parsedMb) { return (dd * 1e7 + ttttt * 1e2 + mm); } +// Ported from SolveTime.multibld_attempt_to_points in solve_time.rb. // See https://www.worldcubeassociation.org/regulations/#9f12c export function attemptResultToMbPoints(mbValue) { let { solved, attempted } = parseMbValue(mbValue); @@ -41,6 +44,7 @@ export function attemptResultToMbPoints(mbValue) { return solved - missed; } +// Ported from SolveTime.points_to_multibld_attempt in solve_time.rb. export function mbPointsToAttemptResult(mbPoints) { let solved = mbPoints; let attempted = mbPoints; @@ -50,11 +54,11 @@ export function mbPointsToAttemptResult(mbPoints) { export function attemptResultToString(attemptResult, eventId, { short } = {}) { let event = events.byId[eventId]; - if(event.timed_event) { + if(event.isTimedEvent) { return centisecondsToString(attemptResult, { short }); - } else if(event.fewest_moves) { + } else if(event.isFewestMoves) { return `${attemptResult} moves`; - } else if(event.multiple_blindfolded) { + } else if(event.isMultipleBlindfolded) { return `${attemptResultToMbPoints(attemptResult)} points`; } else { throw new Error(`Unrecognized event type: ${eventId}`); @@ -63,7 +67,7 @@ export function attemptResultToString(attemptResult, eventId, { short } = {}) { export function matchResult(attemptResult, eventId, { short } = {}) { let event = events.byId[eventId]; - let comparisonString = event.multiple_blindfolded ? "≥" : "≤"; + let comparisonString = event.isMultipleBlindfolded ? "≥" : "≤"; if(!short) { comparisonString = { "≤": "less than or equal to", @@ -107,8 +111,13 @@ export function centisecondsToString(centiseconds, { short } = {}) { } export function roundIdToString(roundId) { - let [ eventId, roundNumber ] = roundId.split("-"); - roundNumber = parseInt(roundNumber); + let { eventId, roundNumber } = parseRoundId(roundId); let event = events.byId[eventId]; return `${event.name}, Round ${roundNumber}`; } + +export function parseRoundId(roundId) { + let [eventId, roundNumber] = roundId.split("-"); + roundNumber = parseInt(roundNumber); + return { eventId, roundNumber }; +} diff --git a/WcaOnRails/app/javascript/polyfills/index.js b/WcaOnRails/app/javascript/polyfills/index.js index cdbe015107..00f940638b 100644 --- a/WcaOnRails/app/javascript/polyfills/index.js +++ b/WcaOnRails/app/javascript/polyfills/index.js @@ -1,50 +1,2 @@ import 'whatwg-fetch'; -import Promise from 'promise-polyfill'; -if(!window.Promise) { - window.Promise = Promise; -} - -// https://tc39.github.io/ecma262/#sec-array.prototype.find -if (!Array.prototype.find) { - Object.defineProperty(Array.prototype, 'find', { - value: function(predicate) { - // 1. Let O be ? ToObject(this value). - if (this === null) { - throw new TypeError('"this" is null or not defined'); - } - - var o = Object(this); - - // 2. Let len be ? ToLength(? Get(O, "length")). - var len = o.length >>> 0; - - // 3. If IsCallable(predicate) is false, throw a TypeError exception. - if (typeof predicate !== 'function') { - throw new TypeError('predicate must be a function'); - } - - // 4. If thisArg was supplied, let T be thisArg; else let T be undefined. - var thisArg = arguments[1]; - - // 5. Let k be 0. - var k = 0; - - // 6. Repeat, while k < len - while (k < len) { - // a. Let Pk be ! ToString(k). - // b. Let kValue be ? Get(O, Pk). - // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)). - // d. If testResult is true, return kValue. - var kValue = o[k]; - if (predicate.call(thisArg, kValue, k, o)) { - return kValue; - } - // e. Increase k by 1. - k++; - } - - // 7. Return undefined. - return undefined; - } - }); -} +import 'babel-polyfill'; diff --git a/WcaOnRails/app/javascript/wca/events.js.erb b/WcaOnRails/app/javascript/wca/events.js.erb index 6f14cf1ada..251985d672 100644 --- a/WcaOnRails/app/javascript/wca/events.js.erb +++ b/WcaOnRails/app/javascript/wca/events.js.erb @@ -1,25 +1,19 @@ +import _ from 'lodash'; import formats from 'wca/formats.js.erb'; export default { official: <%= Event.official.to_json.html_safe %>.map(extend), - byId: mapObjectValues(<%= Event.all.index_by(&:id).to_json.html_safe %>, extend), + byId: _.mapValues(<%= Event.all.index_by(&:id).to_json.html_safe %>, extend), }; -function mapObjectValues(obj, func) { - let newObj = {}; - Object.keys(obj).forEach(key => { - newObj[key] = func(obj[key]); - }); - return newObj; -} - function extend(rawEvent) { + rawEvent = _.mapKeys(rawEvent, (v, k) => _.camelCase(k)); return { ...rawEvent, formats() { - return rawEvent.format_ids.map(format_id => formats.byId[format_id]); + return rawEvent.formatIds.map(formatId => formats.byId[formatId]); }, - recommended_format() { + recommentedFormat() { return this.formats()[0]; }, } diff --git a/WcaOnRails/app/javascript/wca/formats.js.erb b/WcaOnRails/app/javascript/wca/formats.js.erb index 8226002aa2..6ec6d099f7 100644 --- a/WcaOnRails/app/javascript/wca/formats.js.erb +++ b/WcaOnRails/app/javascript/wca/formats.js.erb @@ -1,3 +1,9 @@ +import _ from 'lodash'; + export default { - byId: <%= Format.all.index_by(&:id).to_json.html_safe %>, + byId: _.mapValues(<%= Format.all.index_by(&:id).to_json.html_safe %>, extend), }; + +function extend(rawFormat) { + return _.mapKeys(rawFormat, (v, k) => _.camelCase(k)); +} diff --git a/WcaOnRails/app/models/event.rb b/WcaOnRails/app/models/event.rb index c8dfa24f12..3f7eb67f8a 100644 --- a/WcaOnRails/app/models/event.rb +++ b/WcaOnRails/app/models/event.rb @@ -65,9 +65,9 @@ def serializable_hash(options = nil) name: self.name, format_ids: self.formats.map(&:id), can_change_time_limit: self.can_change_time_limit?, - timed_event: self.timed_event?, - fewest_moves: self.fewest_moves?, - multiple_blindfolded: self.multiple_blindfolded?, + is_timed_event: self.timed_event?, + is_fewest_moves: self.fewest_moves?, + is_multiple_blindfolded: self.multiple_blindfolded?, } end end diff --git a/WcaOnRails/lib/solve_time.rb b/WcaOnRails/lib/solve_time.rb index 811b3326e0..f18c85758a 100644 --- a/WcaOnRails/lib/solve_time.rb +++ b/WcaOnRails/lib/solve_time.rb @@ -160,6 +160,14 @@ def self.multibld_attempt_to_points(attempt_result) SolveTime.new("333mbf", :best, attempt_result).points end + def self.points_to_multibld_attempt(points) + SolveTime.new("333mbf", :best, 0).tap do |solve_time| + solve_time.attempted = points + solve_time.solved = points + solve_time.time_centiseconds = 99_999 + end.wca_value + end + def self.centiseconds_to_clock_format(centiseconds) hours = centiseconds / 360_000 minutes = (centiseconds % 360_000) / 6000 diff --git a/WcaOnRails/package.json b/WcaOnRails/package.json index c8fbce3cf9..9add82779b 100644 --- a/WcaOnRails/package.json +++ b/WcaOnRails/package.json @@ -25,7 +25,6 @@ "postcss-loader": "^2.0.6", "postcss-smart-import": "^0.7.5", "precss": "^2.0.0", - "promise-polyfill": "^6.0.2", "prop-types": "^15.5.8", "rails-erb-loader": "^5.0.2", "react": "^15.5.4", diff --git a/WcaOnRails/spec/features/competition_events_spec.rb b/WcaOnRails/spec/features/competition_events_spec.rb index 382274ba58..942e5e5fb7 100644 --- a/WcaOnRails/spec/features/competition_events_spec.rb +++ b/WcaOnRails/spec/features/competition_events_spec.rb @@ -42,8 +42,7 @@ end save - expect(round_333_1.reload.time_limit).to eq TimeLimit.new(centiseconds: 5.minutes.in_centiseconds, cumulative_round_ids: []) - expect(round_333_1.reload.time_limit.to_s(round_333_1)).to eq "5:00.00" + expect(round_333_1.reload.time_limit_to_s).to eq "5:00.00" end scenario "change cutoff to best of 2 in 2 minutes", js: true do @@ -56,7 +55,7 @@ end save - expect(round_333_1.reload.cutoff.to_s(round_333_1)).to eq "2 attempts to get ≤ 2:00.00" + expect(round_333_1.reload.cutoff_to_s).to eq "2 attempts to get ≤ 2:00.00" end scenario "change advancement condition to top 12 people", js: true do @@ -72,15 +71,13 @@ end save - expect(round_333_1.reload.advancement_condition.to_s(round_333_1)).to eq "Top 12 advance to round 2" + expect(round_333_1.reload.advancement_condition_to_s).to eq "Top 12 advance to round 2" end end end -def within_event_panel(event_id) - within(:css, ".panel.event-#{event_id}") do - yield - end +def within_event_panel(event_id, &block) + within(:css, ".panel.event-#{event_id}", &block) end def within_round(event_id, round_number) diff --git a/WcaOnRails/spec/models/round_spec.rb b/WcaOnRails/spec/models/round_spec.rb index c96dd4bf03..d690e2981f 100644 --- a/WcaOnRails/spec/models/round_spec.rb +++ b/WcaOnRails/spec/models/round_spec.rb @@ -100,7 +100,7 @@ end it "1 attempt to get 4 points or better" do - round.update!(cutoff: Cutoff.new(number_of_attempts: 1, attempt_result: points_to_multibld_attempt(4))) + round.update!(cutoff: Cutoff.new(number_of_attempts: 1, attempt_result: SolveTime.points_to_multibld_attempt(4))) expect(round.cutoff_to_s).to eq "1 attempt to get ≥ 4 points" end end @@ -154,21 +154,13 @@ it "set to >= 6 points" do first_round, _second_round = create_rounds("333mbf", format_id: '3', count: 2) - first_round.update!(advancement_condition: AttemptResultCondition.new(points_to_multibld_attempt(6))) + first_round.update!(advancement_condition: AttemptResultCondition.new(SolveTime.points_to_multibld_attempt(6))) expect(first_round.advancement_condition_to_s).to eq "Best solve ≥ 6 points advances to round 2" end end end end -def points_to_multibld_attempt(points) - SolveTime.new("333mbf", :best, 0).tap do |solve_time| - solve_time.attempted = points - solve_time.solved = points - solve_time.time_centiseconds = 99_999 - end.wca_value -end - def create_rounds(event_id, format_id: 'a', count:) first_round = FactoryGirl.create :round, number: 1, format_id: format_id, event_id: event_id remaining_rounds = (2..count).map do |number| diff --git a/WcaOnRails/yarn.lock b/WcaOnRails/yarn.lock index 6c650eb7e4..12f2c2fcd2 100644 --- a/WcaOnRails/yarn.lock +++ b/WcaOnRails/yarn.lock @@ -4340,10 +4340,6 @@ promise-each@^2.2.0: dependencies: any-promise "^0.1.0" -promise-polyfill@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-6.0.2.tgz#d9c86d3dc4dc2df9016e88946defd69b49b41162" - promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" From e5143e41c4b90cecdb0316822f66f4986c624c96 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Sun, 10 Sep 2017 15:20:15 -0700 Subject: [PATCH 38/42] Fix as many Bullet warnings as possible, and ignore the one remaining one that I do not believe we can fix. --- .../controllers/competitions_controller.rb | 4 ++-- WcaOnRails/app/models/round.rb | 20 +++++++++++++++++-- WcaOnRails/config/environments/development.rb | 4 ++++ WcaOnRails/lib/time_limit.rb | 11 ++-------- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/WcaOnRails/app/controllers/competitions_controller.rb b/WcaOnRails/app/controllers/competitions_controller.rb index e85b5e704b..5ca0cf6ee6 100644 --- a/WcaOnRails/app/controllers/competitions_controller.rb +++ b/WcaOnRails/app/controllers/competitions_controller.rb @@ -318,11 +318,11 @@ def post_results end def show_events - @competition = competition_from_params(includes: [competition_events: { rounds: [:format, :competition_event] }]) + @competition = competition_from_params(includes: [:events, competition_events: { rounds: [:format, :competition_event] }]) end def edit_events - @competition = competition_from_params(includes: [competition_events: { rounds: [:competition_event] }]) + @competition = competition_from_params(includes: [:events, competition_events: { rounds: [:competition_event] }]) end def update_events diff --git a/WcaOnRails/app/models/round.rb b/WcaOnRails/app/models/round.rb index 4c18321ccc..8450361b9e 100644 --- a/WcaOnRails/app/models/round.rb +++ b/WcaOnRails/app/models/round.rb @@ -41,8 +41,20 @@ def final_round? competition_event.rounds.last == self end + def self.parse_wcif_id(wcif_id) + event_id, round_number = wcif_id.split("-") + round_number = round_number.to_i + { event_id: event_id, round_number: round_number } + end + + def self.wcif_id_to_name(wcif_id) + parsed = Round.parse_wcif_id(wcif_id) + event = Event.c_find(parsed[:event_id]) + I18n.t("round.name", event: event.name, number: parsed[:round_number]) + end + def name - I18n.t("round.name", event: event.name, number: self.number) + Round.wcif_id_to_name(wcif_id) end def time_limit_to_s @@ -67,9 +79,13 @@ def self.wcif_to_round_attributes(wcif, round_number) } end + def wcif_id + "#{event.id}-#{self.number}" + end + def to_wcif { - "id" => "#{event.id}-#{self.number}", + "id" => wcif_id, "format" => self.format_id, "timeLimit" => event.can_change_time_limit? ? time_limit&.to_wcif : nil, "cutoff" => cutoff&.to_wcif, diff --git a/WcaOnRails/config/environments/development.rb b/WcaOnRails/config/environments/development.rb index 5dcde677e0..d073e9d687 100644 --- a/WcaOnRails/config/environments/development.rb +++ b/WcaOnRails/config/environments/development.rb @@ -76,6 +76,10 @@ # See https://github.com/thewca/worldcubeassociation.org/pull/1452. This seems to be something # Bullet asks us to include, but isn't necessary, and including it causes a huge performance problem. Bullet.add_whitelist type: :n_plus_one_query, class_name: "Registration", association: :competition_events + + # When loading the edit events page for a competition, Bullet erroneously warns that we are + # not using the rounds association. + Bullet.add_whitelist type: :unused_eager_loading, class_name: "CompetitionEvent", association: :rounds end # Use an evented file watcher to asynchronously detect changes in source code, diff --git a/WcaOnRails/lib/time_limit.rb b/WcaOnRails/lib/time_limit.rb index fea81a5ca7..582142cc07 100644 --- a/WcaOnRails/lib/time_limit.rb +++ b/WcaOnRails/lib/time_limit.rb @@ -47,12 +47,6 @@ def self.dump(time_limit) time_limit ? JSON.dump(time_limit.to_wcif) : nil end - private def wcif_round_id_to_round(competition, wcif_round_id) - event_id, round_number = wcif_round_id.split("-") - competition_event = competition.competition_events.find_by_event_id!(event_id) - competition_event.rounds.find_by_number!(round_number) - end - def self.wcif_json_schema { "type" => ["object", "null"], @@ -71,9 +65,8 @@ def to_s(round) when 1 I18n.t("time_limit.cumulative.one_round", time: time_str) else - rounds = self.cumulative_round_ids.map { |round_id| wcif_round_id_to_round(round.competition, round_id) } - rounds_str = rounds.map(&:name).to_sentence - I18n.t("time_limit.cumulative.across_rounds", time: time_str, rounds: rounds_str) + round_strs = self.cumulative_round_ids.map { |round_id| Round.wcif_id_to_name(round_id) } + I18n.t("time_limit.cumulative.across_rounds", time: time_str, rounds: round_strs.to_sentence) end end end From 18ef0e5a0310c304171e7b10ef97733bf0fba941 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Tue, 12 Sep 2017 12:38:31 -0700 Subject: [PATCH 39/42] Added explicit add and remove event buttons, along with a confirmation message before removing existing rounds of an event. --- .../app/assets/stylesheets/edit_events.scss | 20 ++++- .../app/javascript/edit-events/EditEvents.jsx | 83 ++++++++++++------- .../spec/features/competition_events_spec.rb | 2 +- 3 files changed, 69 insertions(+), 36 deletions(-) diff --git a/WcaOnRails/app/assets/stylesheets/edit_events.scss b/WcaOnRails/app/assets/stylesheets/edit_events.scss index ea95fffb77..e9848a2e86 100644 --- a/WcaOnRails/app/assets/stylesheets/edit_events.scss +++ b/WcaOnRails/app/assets/stylesheets/edit_events.scss @@ -1,8 +1,4 @@ #events-edit-area { - .panel.event-not-being-held { - opacity: 0.5; - } - .panel-heading { padding-top: 10px; padding-bottom: 10px; @@ -26,6 +22,22 @@ padding-left: $cubing-icon-size; padding-right: 5px; } + + .add-event { + margin-left: auto; + } + + .input-group { + margin-left: auto; + + select { + width: 100px; + } + + .input-group-btn { + display: inline-block; + } + } } } diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index d306c56edb..1971f7c521 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -3,6 +3,7 @@ import cn from 'classnames' import ReactDOM from 'react-dom' import events from 'wca/events.js.erb' +import { pluralize } from 'edit-events/modals/utils' import { rootRender, promiseSaveWcif } from 'edit-events' import { EditTimeLimitButton, EditCutoffButton, EditAdvancementConditionButton } from 'edit-events/modals' @@ -149,9 +150,13 @@ function RoundsTable({ wcifEvents, wcifEvent }) { function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { let event = events.byId[wcifEvent.id]; - let roundCountChanged = e => { - let newRoundCount = parseInt(e.target.value); - if(wcifEvent.rounds.length > newRoundCount) { + let setRoundCount = newRoundCount => { + let roundsToRemoveCount = wcifEvent.rounds.length - newRoundCount; + if(roundsToRemoveCount > 0) { + if(!confirm(`Are you sure you want to remove the ${pluralize(roundsToRemoveCount, "round")} of ${event.name}?`)) { + return; + } + // We have too many rounds, remove the extras. wcifEvent.rounds = _.take(wcifEvent.rounds, newRoundCount); @@ -168,40 +173,56 @@ function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { rootRender(); }; - let panelTitle = null; - let disableAdd = false; - let disableRemove = false; - if(competitionConfirmed) { - if(wcifEvent.rounds.length === 0) { - disableAdd = true; - panelTitle = `Cannot add ${wcifEvent.id} because the competition is confirmed.`; - } else { - disableRemove = true; - panelTitle = `Cannot remove ${wcifEvent.id} because the competition is confirmed.`; - } + let roundsCountSelector = null; + if(wcifEvent.rounds.length > 0) { + let disableRemove = competitionConfirmed; + roundsCountSelector = ( + <div className="input-group"> + <select + className="form-control input-xs" + name="select-round-count" + value={wcifEvent.rounds.length} + onChange={e => setRoundCount(parseInt(e.target.value))} + > + <option value={1}>1 round</option> + <option value={2}>2 rounds</option> + <option value={3}>3 rounds</option> + <option value={4}>4 rounds</option> + </select> + + <span className="input-group-btn"> + <button + className="btn btn-danger btn-xs remove-event" + disabled={disableRemove} + title={disableRemove ? `Cannot remove ${event.name} because the competition is confirmed.` : ""} + onClick={() => setRoundCount(0)} + > + Remove event + </button> + </span> + </div> + ); + } else { + let disableAdd = competitionConfirmed; + roundsCountSelector = ( + <button + className="btn btn-success btn-xs add-event" + disabled={disableAdd} + title={disableAdd ? `Cannot add ${event.name} because the competition is confirmed.` : ""} + onClick={() => setRoundCount(1)} + > + Add event + </button> + ); } return ( - <div className={cn(`panel panel-default event-${wcifEvent.id}`, { 'event-not-being-held': wcifEvent.rounds.length == 0 })}> - <div className="panel-heading" title={panelTitle}> + <div className={`panel panel-default event-${wcifEvent.id}`}> + <div className="panel-heading"> <h3 className="panel-title"> <span className={cn("img-thumbnail", "cubing-icon", `event-${event.id}`)}></span> <span className="title">{event.name}</span> - {" "} - <select - className="form-control input-xs" - name="select-round-count" - value={wcifEvent.rounds.length} - onChange={roundCountChanged} - disabled={disableAdd} - > - {!disableRemove && <option value={0}>Not being held</option>} - {!disableRemove && <option disabled="disabled">────────</option>} - <option value={1}>1 round</option> - <option value={2}>2 rounds</option> - <option value={3}>3 rounds</option> - <option value={4}>4 rounds</option> - </select> + {" "}{roundsCountSelector} </h3> </div> diff --git a/WcaOnRails/spec/features/competition_events_spec.rb b/WcaOnRails/spec/features/competition_events_spec.rb index 942e5e5fb7..60e4d20b68 100644 --- a/WcaOnRails/spec/features/competition_events_spec.rb +++ b/WcaOnRails/spec/features/competition_events_spec.rb @@ -14,7 +14,7 @@ background do sign_in FactoryGirl.create(:admin) visit "/competitions/#{competition.id}/events/edit" - within_event_panel("333") { select("1 round", from: "select-round-count") } + within_event_panel("333") { click_button "Add event" } save competition.reload end From 94f995601e84b51e588a61c4312f96149d0ccfbe Mon Sep 17 00:00:00 2001 From: Philippe Virouleau <philippe.44@gmail.com> Date: Tue, 12 Sep 2017 23:02:17 +0200 Subject: [PATCH 40/42] Improved show events page - Spread the rounds across multiple rows instead of columns. - Improved explanation for cutoff and time limit. --- .../app/assets/stylesheets/competitions.scss | 5 ++ .../views/competitions/show_events.html.erb | 50 +++++++++---------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/WcaOnRails/app/assets/stylesheets/competitions.scss b/WcaOnRails/app/assets/stylesheets/competitions.scss index 2fa4ee3533..056fea558d 100644 --- a/WcaOnRails/app/assets/stylesheets/competitions.scss +++ b/WcaOnRails/app/assets/stylesheets/competitions.scss @@ -51,6 +51,11 @@ $competition-nav-padding: 50px; width: calc(100% - (#{$competition-nav-width} + #{$competition-nav-padding})); } + .show-events-table { + .last-round > td { + border-bottom: 4px solid #ccc; + } + } } // Workaround for https://github.com/cubing/icons/issues/16 diff --git a/WcaOnRails/app/views/competitions/show_events.html.erb b/WcaOnRails/app/views/competitions/show_events.html.erb index 16abf1d93f..93fd2c9b17 100644 --- a/WcaOnRails/app/views/competitions/show_events.html.erb +++ b/WcaOnRails/app/views/competitions/show_events.html.erb @@ -3,33 +3,29 @@ <%= render layout: 'nav' do %> <% cumulative_time_limit = false %> <% cumulative_across_rounds_time_limit = false %> - <%= wca_table do %> + <%= wca_table table_class: "show-events-table" do %> <thead> <tr> - <% max_round_number = @competition.competition_events.map { |ce| ce.rounds.length }.max %> - <th rowspan="2">Event name</th> - <% (1..max_round_number).each do |round_number| %> - <th colspan="4">Round <%= round_number %></th> - <% end %> - </tr> - <tr> - <% (1..max_round_number).each do |round_number| %> - <th>Format</th> - <th><%= link_to "Time Limit", "#time-limit" %></th> - <th><%= link_to "Cutoff", "#cutoff" %></th> - <th></th> - <% end %> + <th>Event name</th> + <th>Round</th> + <th>Format</th> + <th><%= link_to "Time Limit", "#time-limit" %></th> + <th><%= link_to "Cutoff", "#cutoff" %></th> + <th></th> </tr> <thead> <tbody> <% @competition.competition_events.each do |competition_event| %> - <% next if competition_event.rounds.length == 0 %> - <tr> - <td> - <%= competition_event.event.name %> - </td> - <% competition_event.rounds.each do |round| %> + <% event_round_number = competition_event.rounds.length %> + <% next if event_round_number == 0 %> + <% competition_event.rounds.each do |round| %> + <% line_class = round.number == event_round_number ? "last-round" : "" %> + <tr class="<%= line_class %>"> + <td> + <%= competition_event.event.name if round.number == 1 %> + </td> + <td><%= round.number %></td> <td><%= round.format.name %></td> <td> <% if !competition_event.event.can_change_time_limit? %> @@ -47,8 +43,8 @@ </td> <td><%= round.cutoff_to_s %></td> <td><%= round.advancement_condition_to_s %></td> - <% end %> - </tr> + </tr> + <% end %> <% end %> </tbody> <% end %> @@ -56,16 +52,18 @@ <dl class="dl-horizontal"> <dt id="time-limit">Time Limit</dt> <dd> - AKA: hard cutoff. + If you reach the time limit during your solve, the judge will stop you and your result will be DNF (see <%= link_to "Regulation A1a4", regulations_path + "#A1a4", target: "_blank" %>). + <br/> <% if cumulative_time_limit %> - A <strong id="cumulative-time-limit">cumulative time limit</strong> may be enforced.... + A <strong id="cumulative-time-limit">cumulative time limit</strong> may be enforced (see <%= link_to "Regulation A1a2", regulations_path + "#A1a2", target: "_blank" %>). <% end %> + <br/> <% if cumulative_across_rounds_time_limit %> - A <strong id="cumulative-across-rounds-time-limit">cumulative time limit</strong> may be enforced across rounds.... + A <strong id="cumulative-across-rounds-time-limit">cumulative time limit</strong> may be enforced across rounds (see <%= link_to "Guideline A1a2++", regulations_path + "/guidelines.html#A1a2++", target: "_blank" %>). <% end %> </dd> <dt id="cutoff">Cutoff</dt> - <dd>What is a cutoff?</dd> + <dd>The ranking or time to meet to proceed to the second phase of a combined round (see <%= link_to "Regulation 9g", regulations_path + "#9g", target: "_blank" %>)</dd> </dl> <% end %> From 6b6bbf20d91815ece478b831b41dc7e5da989fa8 Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Wed, 13 Sep 2017 12:12:44 -0700 Subject: [PATCH 41/42] Add support for 0 rounds in an event. A competition event with 0 rounds means "yes, we're holding this event, but the number of rounds has not been decided yet or has not been announced yet" --- .../app/assets/stylesheets/edit_events.scss | 2 +- .../app/javascript/edit-events/EditEvents.jsx | 23 +++++++++++++++---- .../app/javascript/edit-events/index.jsx | 2 +- .../edit-events/modals/TimeLimit.jsx | 4 ++-- .../javascript/edit-events/modals/index.jsx | 4 ++-- WcaOnRails/app/models/competition.rb | 6 ++--- WcaOnRails/app/models/competition_event.rb | 2 +- .../spec/features/competition_events_spec.rb | 16 ++++++++++++- .../spec/models/competition_wcif_spec.rb | 12 +++++++++- WcaOnRails/spec/requests/competitions_spec.rb | 2 +- 10 files changed, 55 insertions(+), 18 deletions(-) diff --git a/WcaOnRails/app/assets/stylesheets/edit_events.scss b/WcaOnRails/app/assets/stylesheets/edit_events.scss index e9848a2e86..59a2a9fc6d 100644 --- a/WcaOnRails/app/assets/stylesheets/edit_events.scss +++ b/WcaOnRails/app/assets/stylesheets/edit_events.scss @@ -31,7 +31,7 @@ margin-left: auto; select { - width: 100px; + width: 130px; } .input-group-btn { diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index 1971f7c521..ed1681dd5c 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -150,10 +150,21 @@ function RoundsTable({ wcifEvents, wcifEvent }) { function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { let event = events.byId[wcifEvent.id]; + + let removeEvent = () => { + if(!wcifEvent.rounds + || (wcifEvent.rounds.length > 0 && !confirm(`Are you sure you want to remove all ${pluralize(wcifEvent.rounds.length, "round")} of ${event.name}?`))) { + return; + } + + wcifEvent.rounds = null; + rootRender(); + }; let setRoundCount = newRoundCount => { + wcifEvent.rounds = wcifEvent.rounds || []; let roundsToRemoveCount = wcifEvent.rounds.length - newRoundCount; if(roundsToRemoveCount > 0) { - if(!confirm(`Are you sure you want to remove the ${pluralize(roundsToRemoveCount, "round")} of ${event.name}?`)) { + if(!confirm(`Are you sure you want to remove ${pluralize(roundsToRemoveCount, "round")} of ${event.name}?`)) { return; } @@ -174,7 +185,7 @@ function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { }; let roundsCountSelector = null; - if(wcifEvent.rounds.length > 0) { + if(wcifEvent.rounds) { let disableRemove = competitionConfirmed; roundsCountSelector = ( <div className="input-group"> @@ -184,6 +195,8 @@ function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { value={wcifEvent.rounds.length} onChange={e => setRoundCount(parseInt(e.target.value))} > + <option value={0}>How many rounds?</option> + <option disabled="disabled">────────</option> <option value={1}>1 round</option> <option value={2}>2 rounds</option> <option value={3}>3 rounds</option> @@ -195,7 +208,7 @@ function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { className="btn btn-danger btn-xs remove-event" disabled={disableRemove} title={disableRemove ? `Cannot remove ${event.name} because the competition is confirmed.` : ""} - onClick={() => setRoundCount(0)} + onClick={removeEvent} > Remove event </button> @@ -209,7 +222,7 @@ function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { className="btn btn-success btn-xs add-event" disabled={disableAdd} title={disableAdd ? `Cannot add ${event.name} because the competition is confirmed.` : ""} - onClick={() => setRoundCount(1)} + onClick={() => setRoundCount(0)} > Add event </button> @@ -226,7 +239,7 @@ function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { </h3> </div> - {wcifEvent.rounds.length > 0 && ( + {wcifEvent.rounds && ( <div className="panel-body"> <RoundsTable wcifEvents={wcifEvents} wcifEvent={wcifEvent} /> </div> diff --git a/WcaOnRails/app/javascript/edit-events/index.jsx b/WcaOnRails/app/javascript/edit-events/index.jsx index a9248d0515..c4de321bb2 100644 --- a/WcaOnRails/app/javascript/edit-events/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/index.jsx @@ -33,7 +33,7 @@ export function rootRender() { function normalizeWcifEvents(wcifEvents) { return events.official.map(event => { - return _.find(wcifEvents, { id: event.id }) || { id: event.id, rounds: [] }; + return _.find(wcifEvents, { id: event.id }) || { id: event.id, rounds: null }; }); } diff --git a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx index 61d2df2f96..ce779a0f3f 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/TimeLimit.jsx @@ -67,7 +67,7 @@ class SelectRoundsButton extends React.Component { let { timeLimit, excludeEventId, wcifEvents } = this.props; let selectedRoundsById = this.state.selectedRoundsById; - let wcifRounds = _.flatMap(wcifEvents, otherWcifEvent => { + let wcifRounds = _.compact(_.flatMap(wcifEvents, otherWcifEvent => { // Cross round cumulative time limits may not include other rounds of // the same event. // See https://github.com/thewca/wca-regulations/issues/457. @@ -76,7 +76,7 @@ class SelectRoundsButton extends React.Component { return []; } return otherWcifEvent.rounds; - }); + })); return ( <ButtonActivatedModal diff --git a/WcaOnRails/app/javascript/edit-events/modals/index.jsx b/WcaOnRails/app/javascript/edit-events/modals/index.jsx index 4a4b4a8d61..5e8c64c2c3 100644 --- a/WcaOnRails/app/javascript/edit-events/modals/index.jsx +++ b/WcaOnRails/app/javascript/edit-events/modals/index.jsx @@ -20,7 +20,7 @@ let RoundAttributeComponents = { }; function findRoundsSharingTimeLimitWithRound(wcifEvents, wcifRound) { - return _.flatMap(wcifEvents, 'rounds').filter(otherWcifRound => + return _.compact(_.flatMap(wcifEvents, 'rounds')).filter(otherWcifRound => otherWcifRound !== wcifRound && otherWcifRound.timeLimit && otherWcifRound.timeLimit.cumulativeRoundIds.includes(wcifRound.id) @@ -28,7 +28,7 @@ function findRoundsSharingTimeLimitWithRound(wcifEvents, wcifRound) { } function findRounds(wcifEvents, roundIds) { - return _.flatMap(wcifEvents, 'rounds').filter(wcifRound => roundIds.includes(wcifRound.id)); + return _.compact(_.flatMap(wcifEvents, 'rounds')).filter(wcifRound => roundIds.includes(wcifRound.id)); } class EditRoundAttribute extends React.Component { diff --git a/WcaOnRails/app/models/competition.rb b/WcaOnRails/app/models/competition.rb index e3304e3b76..af7e9ca928 100644 --- a/WcaOnRails/app/models/competition.rb +++ b/WcaOnRails/app/models/competition.rb @@ -851,7 +851,7 @@ def set_wcif_events!(wcif_events) # Remove extra events. self.competition_events.each do |competition_event| wcif_event = wcif_events.find { |e| e["id"] == competition_event.event.id } - event_to_be_removed = !wcif_event || !wcif_event["rounds"] || wcif_event["rounds"].empty? + event_to_be_removed = !wcif_event || !wcif_event["rounds"] if event_to_be_removed raise WcaExceptions::BadApiParameter.new("Cannot remove events from a confirmed competition") if self.isConfirmed? competition_event.destroy! @@ -861,7 +861,7 @@ def set_wcif_events!(wcif_events) # Create missing events. wcif_events.each do |wcif_event| event_found = competition_events.find_by_event_id(wcif_event["id"]) - event_to_be_added = wcif_event["rounds"] && !wcif_event["rounds"].empty? + event_to_be_added = wcif_event["rounds"] if !event_found && event_to_be_added raise WcaExceptions::BadApiParameter.new("Cannot add events to a confirmed competition") if self.isConfirmed? competition_events.create!(event_id: wcif_event["id"]) @@ -870,7 +870,7 @@ def set_wcif_events!(wcif_events) # Update all events. wcif_events.each do |wcif_event| - event_to_be_added = wcif_event["rounds"] && !wcif_event["rounds"].empty? + event_to_be_added = wcif_event["rounds"] if event_to_be_added competition_events.find_by_event_id!(wcif_event["id"]).load_wcif!(wcif_event) end diff --git a/WcaOnRails/app/models/competition_event.rb b/WcaOnRails/app/models/competition_event.rb index f5aeec7f99..1cbeee5f51 100644 --- a/WcaOnRails/app/models/competition_event.rb +++ b/WcaOnRails/app/models/competition_event.rb @@ -51,7 +51,7 @@ def self.wcif_json_schema "type" => "object", "properties" => { "id" => { "type" => "string" }, - "rounds" => { "type" => "array", "items" => Round.wcif_json_schema }, + "rounds" => { "type" => ["array", "null"], "items" => Round.wcif_json_schema }, "competitorLimit" => { "type" => "integer" }, "qualification" => { "type" => "object" }, # TODO: expand on this }, diff --git a/WcaOnRails/spec/features/competition_events_spec.rb b/WcaOnRails/spec/features/competition_events_spec.rb index 60e4d20b68..5c35c39f21 100644 --- a/WcaOnRails/spec/features/competition_events_spec.rb +++ b/WcaOnRails/spec/features/competition_events_spec.rb @@ -14,7 +14,10 @@ background do sign_in FactoryGirl.create(:admin) visit "/competitions/#{competition.id}/events/edit" - within_event_panel("333") { click_button "Add event" } + within_event_panel("333") do + click_button "Add event" + select("1 round", from: "select-round-count") + end save competition.reload end @@ -23,6 +26,17 @@ expect(competition.events.map(&:id)).to match_array %w(333) end + scenario "remove event", js: true do + within_event_panel("333") do + click_button "Remove event" + accept_alert "Are you sure you want to remove all 1 round of Rubik's Cube?" + end + save + competition.reload + + expect(competition.events.map(&:id)).to eq [] + end + feature 'change round attributes' do let(:comp_event_333) { competition.competition_events.find_by_event_id("333") } let(:round_333_1) { comp_event_333.rounds.first } diff --git a/WcaOnRails/spec/models/competition_wcif_spec.rb b/WcaOnRails/spec/models/competition_wcif_spec.rb index 4a7571c6be..ea321327e1 100644 --- a/WcaOnRails/spec/models/competition_wcif_spec.rb +++ b/WcaOnRails/spec/models/competition_wcif_spec.rb @@ -115,12 +115,22 @@ describe "#set_wcif_events!" do let(:wcif) { competition.to_wcif } - it "removes competition event when wcif rounds are empty" do + it "does not remove competition event when wcif rounds are empty" do wcif_444_event = wcif["events"].find { |e| e["id"] == "444" } wcif_444_event["rounds"] = [] competition.set_wcif_events!(wcif["events"]) + expect(competition.to_wcif["events"]).to eq(wcif["events"]) + expect(competition.events.map(&:id)).to match_array %w(333 333fm 333mbf 444) + end + + it "does remove competition event when wcif rounds are nil" do + wcif_444_event = wcif["events"].find { |e| e["id"] == "444" } + wcif_444_event["rounds"] = nil + + competition.set_wcif_events!(wcif["events"]) + wcif["events"].reject! { |e| e["id"] == "444" } expect(competition.to_wcif["events"]).to eq(wcif["events"]) expect(competition.events.map(&:id)).to match_array %w(333 333fm 333mbf) diff --git a/WcaOnRails/spec/requests/competitions_spec.rb b/WcaOnRails/spec/requests/competitions_spec.rb index 752caf8752..295a9e42a0 100644 --- a/WcaOnRails/spec/requests/competitions_spec.rb +++ b/WcaOnRails/spec/requests/competitions_spec.rb @@ -220,7 +220,7 @@ }, { id: "222", - rounds: [], + rounds: nil, }, ] patch update_events_from_wcif_path(competition), params: competition_events.to_json, headers: headers From f87409600088667d6e87142d571a1c1ab84cc11d Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman <jeremyfleischman@gmail.com> Date: Wed, 13 Sep 2017 12:20:59 -0700 Subject: [PATCH 42/42] Some ui polish. --- WcaOnRails/app/assets/stylesheets/edit_events.scss | 2 +- WcaOnRails/app/javascript/edit-events/EditEvents.jsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/WcaOnRails/app/assets/stylesheets/edit_events.scss b/WcaOnRails/app/assets/stylesheets/edit_events.scss index 59a2a9fc6d..e9848a2e86 100644 --- a/WcaOnRails/app/assets/stylesheets/edit_events.scss +++ b/WcaOnRails/app/assets/stylesheets/edit_events.scss @@ -31,7 +31,7 @@ margin-left: auto; select { - width: 130px; + width: 100px; } .input-group-btn { diff --git a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx index ed1681dd5c..ce98b05732 100644 --- a/WcaOnRails/app/javascript/edit-events/EditEvents.jsx +++ b/WcaOnRails/app/javascript/edit-events/EditEvents.jsx @@ -76,7 +76,7 @@ export default class EditEvents extends React.Component { <div className="row equal"> {wcifEvents.map(wcifEvent => { return ( - <div key={wcifEvent.id} className="col-xs-12 col-sm-12 col-md-6 col-lg-4"> + <div key={wcifEvent.id} className="col-xs-12 col-sm-12 col-md-12 col-lg-4"> <EventPanel wcifEvents={wcifEvents} wcifEvent={wcifEvent} competitionConfirmed={competitionConfirmed} /> </div> ); @@ -195,7 +195,7 @@ function EventPanel({ wcifEvents, competitionConfirmed, wcifEvent }) { value={wcifEvent.rounds.length} onChange={e => setRoundCount(parseInt(e.target.value))} > - <option value={0}>How many rounds?</option> + <option value={0}># of rounds?</option> <option disabled="disabled">────────</option> <option value={1}>1 round</option> <option value={2}>2 rounds</option>