diff --git a/app/components/compare-reasons.jsx b/app/components/compare-reasons.jsx deleted file mode 100644 index b0d4cf0f0..000000000 --- a/app/components/compare-reasons.jsx +++ /dev/null @@ -1,88 +0,0 @@ -// https://github.com/EnCiv/civil-pursuit/issues/77 - -'use strict' -import React, { useEffect, useState } from 'react' -import { createUseStyles } from 'react-jss' -import PairCompare from './pair-compare' -import { H, Level } from 'react-accessible-headings' - -function CompareReasons(props) { - const { pointList = [], side = '', onDone = () => {}, className, ...otherProps } = props - const classes = useStyles() - const [completedPoints, setCompletedPoints] = useState(new Set()) - const [percentDone, setPercentDone] = useState(0) - - useEffect(() => { - if (completedPoints.size === pointList.length) { - onDone({ valid: true, value: percentDone }) - } else { - onDone({ valid: false, value: percentDone }) - } - }, [completedPoints, percentDone]) - - useEffect(() => { - if (pointList.length === 0) setPercentDone(100) - else { - setPercentDone(Number(((completedPoints.size / pointList.length) * 100).toFixed(2))) - } - }, [completedPoints, pointList]) - - const handlePairCompare = ({ valid, value }, idx) => { - setCompletedPoints(prevPoints => { - const updatedPoints = new Set(prevPoints) - if (valid) { - updatedPoints.add(idx) - } else { - updatedPoints.delete(idx) - } - return updatedPoints - }) - } - - return ( -
- {pointList.map((headlinePoint, idx) => ( -
- Please choose the most convincing explanation for... - {headlinePoint.subject} - - handlePairCompare(value, idx)} - /> - -
- ))} -
- ) -} - -const useStyles = createUseStyles(theme => ({ - container: { - fontFamily: theme.font.fontFamily, - }, - headlinePoint: { - borderTop: '0.0625rem solid #000000', - marginBottom: '4rem', - paddingTop: '2rem', - '&:first-child': { - borderTop: 'none', - }, - }, - headlineTitle: { - fontWeight: '600', - fontSize: '1.5rem', - lineHeight: '2rem', - }, - headlineSubject: { - fontWeight: '300', - fontSize: '2.25rem', - lineHeight: '2.9375rem', - }, - pairCompare: { - marginTop: '1rem', - }, -})) - -export default CompareReasons diff --git a/app/components/deliberation-context.js b/app/components/deliberation-context.js index 61e6a393d..b58f3ad81 100644 --- a/app/components/deliberation-context.js +++ b/app/components/deliberation-context.js @@ -1,5 +1,6 @@ import React, { createContext, useCallback, useState, useRef } from 'react' -import { merge } from 'lodash' +import setOrDeleteByMutatePath from '../lib/set-or-delete-by-mutate-path' + export const DeliberationContext = createContext({}) export default DeliberationContext @@ -12,19 +13,9 @@ export function DeliberationContextProvider(props) { const upsert = useCallback( obj => { setData(data => { - // if something changes in a top level prop, the top level ref has to be changed so it will cause a rerender - const newData = { ...data } - Object.keys(obj).forEach(key => { - if (typeof obj[key] !== 'object') { - newData[key] = obj[key] - } else { - const newProp = Array.isArray(obj[key]) ? [] : {} - merge(newProp, data[key], obj[key]) - newData[key] = newProp - } - }) - deriveReducedPointList(newData, local) - return newData // spread because we need to return a new reference + let newData = setOrDeleteByMutatePath(data, obj) + newData = deriveReducedPointList(newData, local) + return newData // is a new ref is there were changes above, or may be the original ref if no changes }) }, [setData] @@ -53,10 +44,7 @@ export function deriveReducedPointList(data, local) { const { pointById, groupIdsLists } = data if (!pointById || !groupIdsLists) return data if (local.pointById === pointById && local.groupIdsList === groupIdsLists) return data // nothing to update - const reducedPointTable = Object.entries(pointById).reduce( - (reducedPointTable, [id, point]) => ((reducedPointTable[id] = { point }), reducedPointTable), - {} - ) + const reducedPointTable = Object.entries(pointById).reduce((reducedPointTable, [id, point]) => ((reducedPointTable[id] = { point }), reducedPointTable), {}) let updated = false for (const [firstId, ...groupIds] of groupIdsLists) { reducedPointTable[firstId].group = groupIds.map(id => reducedPointTable[id].point) @@ -66,17 +54,15 @@ export function deriveReducedPointList(data, local) { // then copy them over so they are unchanged for (const pointWithGroup of data.reducedPointList) { const ptid = pointWithGroup.point._id - if ( - reducedPointTable[ptid]?.point === pointWithGroup.point && - aEqual(reducedPointTable[ptid]?.group, pointWithGroup.group) - ) - reducedPointTable[ptid] = pointWithGroup // if contentss are unchanged - unchange the ref + if (reducedPointTable[ptid]?.point === pointWithGroup.point && aEqual(reducedPointTable[ptid]?.group, pointWithGroup.group)) reducedPointTable[ptid] = pointWithGroup // if contentss are unchanged - unchange the ref else updated = true } const newReducedPointList = Object.values(reducedPointTable) local.pointById = pointById local.groupIdsList = groupIdsLists - if (!(newReducedPointList.length === data.reducedPointList.length && !updated)) + if (!(newReducedPointList.length === data.reducedPointList.length && !updated)) { data.reducedPointList = newReducedPointList + return { ...data } + } return data } diff --git a/app/components/pair-compare.jsx b/app/components/pair-compare.jsx index 9b648c9ea..e3b112bcd 100644 --- a/app/components/pair-compare.jsx +++ b/app/components/pair-compare.jsx @@ -1,170 +1,143 @@ // https://github.com/EnCiv/civil-pursuit/issues/53 +// https://github.com/EnCiv/civil-pursuit/issues/200 'use strict' -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import Point from './point' import { createUseStyles } from 'react-jss' import { SecondaryButton } from './button.jsx' +import ObjectId from 'bson-objectid' +import cx from 'classnames' function PairCompare(props) { - const { pointList = [], onDone = () => {}, mainPoint = { subject: '', description: '' }, ...otherProps } = props + const { whyRankList = [], onDone = () => {}, mainPoint = { subject: '', description: '' }, discussionId, round, ...otherProps } = props // idxLeft and idxRight can swap places at any point - they are simply pointers to the current two elements const [idxLeft, setIdxLeft] = useState(0) const [idxRight, setIdxRight] = useState(1) - - const isInitialRender = useRef(true) - - const visibleRightPointRef = useRef(null) - const visibleLeftPointRef = useRef(null) - const hiddenLeftPointRef = useRef(null) - const hiddenRightPointRef = useRef(null) - - const [pointsIdxCounter, setPointsIdxCounter] = useState(1) - const [selectedPoint, setSelectedPoint] = useState(null) + const [nextLeftPoint, setNextLeftPoint] = useState(null) + const [nextRightPoint, setNextRightPoint] = useState(null) const [isRightTransitioning, setIsRightTransitioning] = useState(false) const [isLeftTransitioning, setIsLeftTransitioning] = useState(false) const classes = useStyles() - - useEffect(() => { - if (isSelectionComplete()) { - setSelectedPoint(pointList[idxLeft] ? pointList[idxLeft] : pointList[idxRight]) - } - }, [pointsIdxCounter]) - + const [ranksByParentId, neverSetRanksByParentId] = useState(whyRankList.reduce((ranksByParentId, whyRank) => (whyRank.rank && (ranksByParentId[whyRank.rank.parentId] = whyRank.rank), ranksByParentId), {})) + // if all points are ranked, show the (first) one ranked most important, go to start over state + // otherwise compare the list useEffect(() => { - if (isInitialRender.current) { - isInitialRender.current = false - return - } - - if (selectedPoint) { - onDone({ valid: true, value: selectedPoint }) - } else { - onDone({ valid: false, value: null }) + let selectedIdx + let updated = 0 + whyRankList.forEach((whyRank, i) => { + if (whyRank.rank && ranksByParentId[whyRank.rank.parentId] !== whyRank.rank) { + updated++ + ranksByParentId[whyRank.rank.parentId] = whyRank.rank + if (whyRank.rank.category === 'most') selectedIdx = i + } + }) + const ranks = Object.values(ranksByParentId) + if (updated && ranks.length === whyRankList.length && ranks.length > 0 && ranks.every(rank => rank.category)) { + // skip if an update from above after the user has completed ranking - likely this is the initial render + setIdxLeft(selectedIdx ?? whyRankList.length) // idx could be 0 + setIdxRight(whyRankList.length) + setTimeout(() => onDone({ valid: true, value: undefined })) } - }, [selectedPoint]) + }, [whyRankList]) + + // send up the rank, and track it locally + function rankIdxCategory(idx, category) { + if (idx >= whyRankList.length) return // if only one to rank, this could be out of bounds + const value = ranksByParentId[whyRankList[idx]._id] + ? { ...ranksByParentId[whyRankList[idx]._id], category } + : { + _id: ObjectId().toString(), + category, + parentId: whyRankList[idx].why._id, + stage: 'why', + discussionId, + round, + } + ranksByParentId[value.parentId] = value + const ranks = Object.values(ranksByParentId) + const valid = ranks.length === whyRankList.length && ranks.every(rank => rank.category) + setTimeout(() => onDone({ valid, value })) + } const handleLeftPointClick = () => { - // prevent transitions from firing on last comparison - if (idxRight >= pointList.length - 1 || idxLeft >= pointList.length - 1) { - if (idxLeft >= idxRight) { - setIdxRight(idxLeft + 1) - } else { - setIdxRight(idxRight + 1) - } - setPointsIdxCounter(pointsIdxCounter + 1) + rankIdxCategory(idxRight, 'neutral') + if (Math.max(idxLeft, idxRight) + 1 >= whyRankList.length) { + // they've all been ranked + rankIdxCategory(idxLeft, 'most') + setIdxRight(whyRankList.length) return } - + const nextRightIdx = Math.min(idxLeft >= idxRight ? idxLeft + 1 : idxRight + 1, whyRankList.length) setIsLeftTransitioning(true) - const visiblePointRight = visibleRightPointRef.current - const hiddenPointRight = hiddenRightPointRef.current - - Object.assign(visiblePointRight.style, { - position: 'relative', - transform: 'translateX(200%)', - transition: 'transform 0.5s linear', - }) - Object.assign(hiddenPointRight.style, { - position: 'absolute', - transform: 'translateY(8.25rem)', - transition: 'transform 0.5s linear', - }) + setNextRightPoint(whyRankList[nextRightIdx].why) setTimeout(() => { setIsLeftTransitioning(false) - if (idxLeft >= idxRight) { - setIdxRight(idxLeft + 1) - } else { - setIdxRight(idxRight + 1) - } - - setPointsIdxCounter(pointsIdxCounter + 1) - - Object.assign(visiblePointRight.style, { position: '', transition: 'none', transform: '' }) - Object.assign(hiddenPointRight.style, { position: '', transition: 'none', transform: '' }) + setNextRightPoint(null) + setIdxRight(nextRightIdx) }, 500) } const handleRightPointClick = () => { - // prevent transitions from firing on last comparison - if (idxRight >= pointList.length - 1 || idxLeft >= pointList.length - 1) { - if (idxLeft >= idxRight) { - setIdxLeft(idxLeft + 1) - } else { - setIdxLeft(idxRight + 1) - } - setPointsIdxCounter(pointsIdxCounter + 1) + rankIdxCategory(idxLeft, 'neutral') + if (Math.max(idxLeft, idxRight) + 1 >= whyRankList.length) { + // they've all been ranked + rankIdxCategory(idxRight, 'most') + setIdxLeft(whyRankList.length) return } - + const nextLeftIdx = Math.min(idxLeft >= idxRight ? idxLeft + 1 : idxRight + 1, whyRankList.length) + setNextLeftPoint(whyRankList[nextLeftIdx].why) setIsRightTransitioning(true) - const visiblePointLeft = visibleLeftPointRef.current - const hiddenPointLeft = hiddenLeftPointRef.current - - Object.assign(visiblePointLeft.style, { - position: 'relative', - transform: 'translateX(-200%)', - transition: 'transform 0.5s linear', - }) - Object.assign(hiddenPointLeft.style, { - position: 'absolute', - transform: 'translateY(8.25rem)', - transition: 'transform 0.5s linear', - }) setTimeout(() => { setIsRightTransitioning(false) - if (idxLeft >= idxRight) { - setIdxLeft(idxLeft + 1) - } else { - setIdxLeft(idxRight + 1) - } - - setPointsIdxCounter(pointsIdxCounter + 1) - - Object.assign(visiblePointLeft.style, { position: '', transition: 'none', transform: '' }) - Object.assign(hiddenPointLeft.style, { position: '', transition: 'none', transform: '' }) + setNextLeftPoint(null) + setIdxLeft(nextLeftIdx) }, 500) } const handleNeitherButton = () => { - if (selectedPoint) return + rankIdxCategory(idxLeft, 'neutral') + rankIdxCategory(idxRight, 'neutral') if (idxLeft >= idxRight) { - setIdxRight(idxLeft + 1) - setIdxLeft(idxLeft + 2) + setIdxRight(Math.min(idxLeft + 1, whyRankList.length)) + setIdxLeft(Math.min(idxLeft + 2, whyRankList.length)) } else { - setIdxLeft(idxRight + 1) - setIdxRight(idxRight + 2) + setIdxLeft(Math.min(idxRight + 1, whyRankList.length)) + setIdxRight(Math.min(idxRight + 2, whyRankList.length)) } - - setPointsIdxCounter(pointsIdxCounter + 2) } const handleStartOverButton = () => { + Object.values(ranksByParentId).forEach(rank => (rank.category = undefined)) // reset the categories so we can start again onDone({ valid: false, value: null }) setIdxRight(1) setIdxLeft(0) - setPointsIdxCounter(1) - setSelectedPoint(null) } - const isSelectionComplete = () => { - return pointsIdxCounter >= pointList.length + const handleYes = () => { + if (idxLeft < whyRankList.length) { + rankIdxCategory(idxLeft, 'most') + } else if (idxRight < whyRankList.length) { + rankIdxCategory(idxRight, 'most') + } + } + const handleNo = () => { + if (idxLeft < whyRankList.length) { + rankIdxCategory(idxLeft, 'neutral') + } else if (idxRight < whyRankList.length) { + rankIdxCategory(idxRight, 'neutral') + } } - const nextIndex = idxLeft > idxRight ? idxLeft + 1 : idxRight + 1 - const hiddenEmptyLeftPoint = - const hiddenTransitioningLeftPoint = ( - - ) - const hiddenEmptyRightPoint = - const hiddenTransitioningRightPoint = ( - - ) - + const pointsIdxCounter = Math.max(idxLeft, idxRight) + const isSelectionComplete = pointsIdxCounter >= whyRankList.length + const ranks = isSelectionComplete && Object.values(ranksByParentId) // isSelectionComple here so we don't do work if not needed + const allRanked = isSelectionComplete && ranks.length === whyRankList.length && ranks.every(rank => rank.category) // isSelectionComple here so we don't do work if not needed return (
@@ -172,55 +145,38 @@ function PairCompare(props) {
{mainPoint.description}
- {`${ - pointsIdxCounter <= pointList.length ? pointsIdxCounter : pointList.length - } out of ${pointList.length}`} + {`${pointsIdxCounter <= whyRankList.length ? pointsIdxCounter : whyRankList.length} out of ${whyRankList.length}`}
- {pointsIdxCounter < pointList.length && ( -
- {isRightTransitioning ? hiddenTransitioningLeftPoint : hiddenEmptyLeftPoint} -
- )} - {pointsIdxCounter < pointList.length && ( -
- {isLeftTransitioning ? hiddenTransitioningRightPoint : hiddenEmptyRightPoint} -
- )} +
= whyRankList.length - 1 && classes.hidden)}> + +
+
= whyRankList.length - 1 && classes.hidden)}> + +
-
- {idxLeft < pointList.length && ( - )} - {idxRight < pointList.length && ( - )}
-
- {!isSelectionComplete() ? ( - Neither - ) : ( - Start Over - )} -
+ {!isSelectionComplete || allRanked ? ( +
{!isSelectionComplete ? Neither : Start Over}
+ ) : ( +
+ Yes +
+ No +
+ )}
) @@ -261,6 +217,9 @@ const useStyles = createUseStyles(theme => ({ marginBottom: '1rem', clipPath: 'xywh(0 0 100% 500%)', }, + hidden: { + display: 'none', + }, hiddenPoint: { width: '30%', }, @@ -294,6 +253,21 @@ const useStyles = createUseStyles(theme => ({ justifyContent: 'center', margin: '2rem auto', }, + transitioningDown: { + position: 'absolute', + transform: 'translateY(8.25rem)', + transition: 'transform 0.5s linear', + }, + transitioningLeft: { + position: 'relative', + transform: 'translateX(-200%)', + transition: 'transform 0.5s linear', + }, + transitioningRight: { + position: 'relative', + transform: 'translateX(200%)', + transition: 'transform 0.5s linear', + }, })) const sharedStatusBadgeStyle = () => ({ diff --git a/app/components/ranking.jsx b/app/components/ranking.jsx index 75e85c326..34ae389b6 100644 --- a/app/components/ranking.jsx +++ b/app/components/ranking.jsx @@ -14,22 +14,19 @@ const selectedOption = ( const unselectedOption = export default function Ranking(props) { - //Isolate props and set initial state const { disabled, defaultValue, className, onDone, ...otherProps } = props let [response, setResponse] = useState(responseOptions.includes(defaultValue) ? defaultValue : '') useEffect(() => { - if (defaultValue) { - if (!responseOptions.includes(defaultValue)) { - setResponse(undefined) - onDone && onDone({ valid: false, value: '' }) - } else { - setResponse(defaultValue) - onDone && onDone({ valid: true, value: defaultValue }) - } + if (!defaultValue && !response) return // do not call onDone if initally empty, or if change to empty from above when it's already empty + if (responseOptions.includes(defaultValue)) { + setResponse(defaultValue) + onDone && onDone({ valid: true, value: defaultValue }) + } else { + setResponse('') + onDone && onDone({ valid: false, value: '' }) } }, [defaultValue]) - //Introduce component styling const styleClasses = rankingStyleClasses(props) const onSelectionChange = e => { @@ -38,23 +35,17 @@ export default function Ranking(props) { } setResponse(e.target.value) if (!onDone) { - return console.warn( - `Unhandled rank selection: ${e.target.value}. Please pass a handler function via the onDone prop.` - ) + return console.warn(`Unhandled rank selection: ${e.target.value}. Please pass a handler function via the onDone prop.`) } else onDone({ valid: true, value: e.target.value }) } return ( -
+
{responseOptions.map(option => { return (