Stash rating system (#2830)

* add rating100 fields to represent rating range 1-100
* deprecate existing (1-5) rating fields
* add half- and quarter-star options for rating system
* add decimal rating system option

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
skier233
2022-11-15 17:31:44 -05:00
committed by GitHub
parent f66333bac9
commit 7eae751d1c
133 changed files with 2192 additions and 761 deletions

View File

@@ -0,0 +1,118 @@
import React, { useRef } from "react";
export interface IRatingNumberProps {
value?: number;
onSetRating?: (value?: number) => void;
disabled?: boolean;
}
export const RatingNumber: React.FC<IRatingNumberProps> = (
props: IRatingNumberProps
) => {
const text = ((props.value ?? 0) / 10).toFixed(1);
const useValidation = useRef(true);
function stepChange() {
useValidation.current = false;
}
function nonStepChange() {
useValidation.current = true;
}
function setCursorPosition(
target: HTMLInputElement,
pos: number,
endPos?: number
) {
// This is a workaround to a missing feature where you can't set cursor position in input numbers.
// See https://stackoverflow.com/questions/33406169/failed-to-execute-setselectionrange-on-htmlinputelement-the-input-elements
target.type = "text";
target.setSelectionRange(pos, endPos ?? pos);
target.type = "number";
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!props.onSetRating) {
return;
}
let val = e.target.value;
if (!useValidation.current) {
e.target.value = Number(val).toFixed(1);
const tempVal = Number(val) * 10;
props.onSetRating(tempVal != 0 ? tempVal : undefined);
useValidation.current = true;
return;
}
const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val);
const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? "");
if (match == null || props.onSetRating == null) {
return;
}
if (match[2] && !(match[2] == "0" && match[1] == "1")) {
match[2] = "";
}
if (match[4] == null || match[4] == "") {
match[4] = "0";
}
let value = match[1] + match[2] + "." + match[4];
e.target.value = value;
if (val.length > 0) {
if (Number(value) > 10) {
value = "10.0";
}
e.target.value = Number(value).toFixed(1);
let tempVal = Number(value) * 10;
props.onSetRating(tempVal != 0 ? tempVal : undefined);
let cursorPosition = 0;
if (match[2] && !match[4]) {
cursorPosition = 3;
} else if (matchOld != null && match[1] !== matchOld[1]) {
cursorPosition = 2;
} else if (
matchOld != null &&
match[1] === matchOld[1] &&
match[2] === matchOld[2] &&
match[4] === matchOld[4]
) {
cursorPosition = 2;
}
setCursorPosition(e.target, cursorPosition);
}
}
if (props.disabled) {
return (
<div className="rating-number disabled">
<span>{Number((props.value ?? 0) / 10).toFixed(1)}</span>
</div>
);
} else {
return (
<div className="rating-number">
<input
className="text-input form-control"
name="ratingnumber"
type="number"
onMouseDown={stepChange}
onKeyDown={nonStepChange}
onChange={handleChange}
value={text}
min="0.0"
step="0.1"
max="10"
placeholder="0.0"
/>
</div>
);
}
};

View File

@@ -0,0 +1,217 @@
import React, { useState } from "react";
import { Button } from "react-bootstrap";
import Icon from "src/components/Shared/Icon";
import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons";
import { faStar as farStar } from "@fortawesome/free-regular-svg-icons";
import {
convertFromRatingFormat,
convertToRatingFormat,
getRatingPrecision,
RatingStarPrecision,
RatingSystemType,
} from "src/utils/rating";
import { useIntl } from "react-intl";
export interface IRatingStarsProps {
value?: number;
onSetRating?: (value?: number) => void;
disabled?: boolean;
precision: RatingStarPrecision;
}
export const RatingStars: React.FC<IRatingStarsProps> = (
props: IRatingStarsProps
) => {
const intl = useIntl();
const [hoverRating, setHoverRating] = useState<number | undefined>();
const disabled = props.disabled || !props.onSetRating;
const rating = convertToRatingFormat(props.value, {
type: RatingSystemType.Stars,
starPrecision: props.precision,
});
const stars = rating ? Math.floor(rating) : 0;
const fraction = rating ? rating % 1 : 0;
const max = 5;
const precision = getRatingPrecision(props.precision);
function newToggleFraction() {
if (precision !== 1) {
if (fraction !== precision) {
if (fraction == 0) {
return 1 - precision;
}
return fraction - precision;
}
}
}
function setRating(thisStar: number) {
if (!props.onSetRating) {
return;
}
let newRating: number | undefined = thisStar;
// toggle rating fraction if we're clicking on the current rating
if (
(stars === thisStar && !fraction) ||
(stars + 1 === thisStar && fraction)
) {
const f = newToggleFraction();
if (!f) {
newRating = undefined;
} else if (fraction) {
// we're toggling from an existing fraction so use the stars value
newRating = stars + f;
} else {
// we're toggling from a whole value, so decrement from current rating
newRating = stars - 1 + f;
}
}
// set the hover rating to undefined so that it doesn't immediately clear
// the stars
setHoverRating(undefined);
if (!newRating) {
props.onSetRating(undefined);
return;
}
props.onSetRating(
convertFromRatingFormat(newRating, RatingSystemType.Stars)
);
}
function onMouseOver(thisStar: number) {
if (!disabled) {
setHoverRating(thisStar);
}
}
function onMouseOut(thisStar: number) {
if (!disabled && hoverRating === thisStar) {
setHoverRating(undefined);
}
}
function getClassName(thisStar: number) {
if (hoverRating && hoverRating >= thisStar) {
if (hoverRating === stars) {
return "unsetting";
}
return "setting";
}
if (stars && stars >= thisStar) {
return "set";
}
return "unset";
}
function getTooltip(thisStar: number, current: RatingFraction | undefined) {
if (disabled) {
if (rating) {
// always return current rating for disabled control
return rating.toString();
}
return undefined;
}
// adjust tooltip to use fractions
if (!current) {
return intl.formatMessage({ id: "actions.unset" });
}
return (current.rating + current.fraction).toString();
}
type RatingFraction = {
rating: number;
fraction: number;
};
function getCurrentSelectedRating(): RatingFraction | undefined {
let r: number = hoverRating ? hoverRating : stars;
let f: number | undefined = fraction;
if (hoverRating) {
if (hoverRating === stars && precision === 1) {
// unsetting
return undefined;
}
if (hoverRating === stars + 1 && fraction && fraction === precision) {
// unsetting
return undefined;
}
if (f && hoverRating === stars + 1) {
f = newToggleFraction();
r--;
} else if (!f && hoverRating === stars) {
f = newToggleFraction();
r--;
} else {
f = 0;
}
}
return { rating: r, fraction: f ?? 0 };
}
function getButtonClassName(
thisStar: number,
current: RatingFraction | undefined
) {
if (!current || thisStar > current.rating + 1) {
return "star-fill-0";
}
if (thisStar <= current.rating) {
return "star-fill-100";
}
let w = current.fraction * 100;
return `star-fill-${w}`;
}
const renderRatingButton = (thisStar: number) => {
const ratingFraction = getCurrentSelectedRating();
return (
<Button
disabled={disabled}
className={`minimal ${getButtonClassName(thisStar, ratingFraction)}`}
onClick={() => setRating(thisStar)}
variant="secondary"
onMouseEnter={() => onMouseOver(thisStar)}
onMouseLeave={() => onMouseOut(thisStar)}
onFocus={() => onMouseOver(thisStar)}
onBlur={() => onMouseOut(thisStar)}
title={getTooltip(thisStar, ratingFraction)}
key={`star-${thisStar}`}
>
<div className="filled-star">
<Icon icon={fasStar} className="set" />
</div>
<div className="unfilled-star">
<Icon icon={farStar} className={getClassName(thisStar)} />
</div>
</Button>
);
};
return (
<div className="rating-stars">
{Array.from(Array(max)).map((value, index) =>
renderRatingButton(index + 1)
)}
</div>
);
};

View File

@@ -0,0 +1,50 @@
import React from "react";
import { IUIConfig } from "src/core/config";
import { ConfigurationContext } from "src/hooks/Config";
import {
defaultRatingStarPrecision,
defaultRatingSystemOptions,
RatingSystemType,
} from "src/utils/rating";
import { RatingNumber } from "./RatingNumber";
import { RatingStars } from "./RatingStars";
export interface IRatingSystemProps {
value?: number;
onSetRating?: (value?: number) => void;
disabled?: boolean;
}
export const RatingSystem: React.FC<IRatingSystemProps> = (
props: IRatingSystemProps
) => {
const { configuration: config } = React.useContext(ConfigurationContext);
const ratingSystemOptions =
(config?.ui as IUIConfig)?.ratingSystemOptions ??
defaultRatingSystemOptions;
function getRatingStars() {
return (
<RatingStars
value={props.value}
onSetRating={props.onSetRating}
disabled={props.disabled}
precision={
ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision
}
/>
);
}
if (ratingSystemOptions.type === RatingSystemType.Stars) {
return getRatingStars();
} else {
return (
<RatingNumber
value={props.value}
onSetRating={props.onSetRating}
disabled={props.disabled}
/>
);
}
};

View File

@@ -0,0 +1,62 @@
.rating-stars {
display: inline-flex;
vertical-align: middle;
button {
font-size: inherit;
margin-right: 1px;
padding: 0;
position: relative;
&:hover {
background-color: inherit;
}
&:disabled {
background-color: inherit;
opacity: inherit;
}
&.star-fill-0 .filled-star {
width: 0;
}
&.star-fill-25 .filled-star {
width: 35%;
}
&.star-fill-50 .filled-star {
width: 50%;
}
&.star-fill-75 .filled-star {
width: 65%;
}
&.star-fill-100 .filled-star {
width: 100%;
}
.filled-star {
overflow: hidden;
position: absolute;
}
}
.unsetting {
color: gold;
}
.setting {
color: gold;
}
.set {
color: gold;
}
}
.rating-number.disabled {
align-items: center;
display: inline-flex;
}