File size: 7,528 Bytes
55d0eca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89250ee
 
55d0eca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89250ee
 
 
 
 
 
 
 
 
 
 
 
55d0eca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { artifactsApi } from "../api/endpoints";
import type { AnswerFeedback as Feedback, Artifact } from "../api/types";
import { Thumb } from "./AnswerFeedback";
import { errorMessage } from "./ui";

/** What can be rated: finished reports, quizzes, flashcard sets and infographics. */
export const RATEABLE = ["report", "quiz", "flashcards", "infographic"];

/** Why a thumbs down, per kind (the ids the server keeps; see services/artifact_feedback.py). */
const REASONS: Record<string, { id: string; label: string }[]> = {
  report: [
    { id: "inaccurate", label: "Inaccurate or made up" },
    { id: "missed_points", label: "Missed key points" },
    { id: "structure", label: "Poorly structured" },
    { id: "tone", label: "Wrong tone or style" },
    { id: "too_long", label: "Too long" },
    { id: "too_short", label: "Too short" },
    { id: "other", label: "Something else" },
  ],
  quiz: [
    { id: "wrong_answers", label: "Wrong answers" },
    { id: "too_easy", label: "Too easy" },
    { id: "too_hard", label: "Too hard" },
    { id: "unclear", label: "Unclear questions" },
    { id: "off_topic", label: "Not from my material" },
    { id: "other", label: "Something else" },
  ],
  flashcards: [
    { id: "inaccurate", label: "Inaccurate" },
    { id: "too_basic", label: "Too basic" },
    { id: "too_detailed", label: "Too detailed" },
    { id: "unclear", label: "Unclear wording" },
    { id: "other", label: "Something else" },
  ],
  infographic: [
    { id: "messy_layout", label: "Messy layout" },
    { id: "cut_off", label: "Text cut off or overlapping" },
    { id: "missing_content", label: "Wrong or missing content" },
    { id: "look", label: "The look does not suit" },
    { id: "hard_to_read", label: "Hard to read" },
    { id: "other", label: "Something else" },
  ],
};

// A rating is shown wherever its artifact is (the card, the viewer, the results), so they share one copy.
const ratings = new Map<string, Feedback | null>();
const listeners = new Set<() => void>();
const subscribe = (fn: () => void) => {
  listeners.add(fn);
  return () => listeners.delete(fn);
};
function put(id: string, fb: Feedback | null) {
  ratings.set(id, fb);
  listeners.forEach((l) => l());
}

/** Thumbs up and thumbs down on a generated report, quiz, flashcard set or infographic, so it is clear which ones are
 * good. A thumbs down asks why (optional). Saved as soon as it is chosen; shared by every place the artifact shows.
 * `compact` opens the reasons over the page instead of pushing it down. */
export function ArtifactFeedback({ artifact, label, compact = false }: { artifact: Artifact; label?: ReactNode; compact?: boolean }) {
  const initial = artifact.feedback ?? null;
  const fb = useSyncExternalStore(subscribe, () => (ratings.has(artifact.id) ? ratings.get(artifact.id)! : initial), () => initial);
  const [open, setOpen] = useState(false);
  const [reasons, setReasons] = useState<string[]>(fb?.reasons ?? []);
  const [comment, setComment] = useState(fb?.comment ?? "");
  const [error, setError] = useState<string | null>(null);
  const [thanks, setThanks] = useState(false);
  const options = REASONS[artifact.type] ?? [];
  const whyRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (open) whyRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); // in a fixed size popup it can open below the fold
  }, [open, fb?.rating]);
  if (!RATEABLE.includes(artifact.type) || artifact.status !== "ready") return null;

  const save = async (rating: "up" | "down" | null, r: string[] = [], c = "") => {
    const before = fb;
    setError(null);
    put(artifact.id, rating ? { rating, reasons: r, comment: c || null } : null); // shows at once; undone if the save fails
    try {
      const res = await artifactsApi.rate(artifact.id, { rating, reasons: r, comment: c || undefined });
      put(artifact.id, res.feedback);
    } catch (e) {
      put(artifact.id, before);
      setError(errorMessage(e));
    }
  };

  const choose = (rating: "up" | "down") => {
    setThanks(false);
    if (fb?.rating === rating) {
      setOpen(false);
      void save(null);
      return;
    }
    setOpen(true); // both ratings can take a few words (a thumbs down also asks why)
    if (rating === "up") {
      setReasons([]);
      setComment("");
    }
    void save(rating, rating === "down" ? reasons : []);
  };

  return (
    <div className={`fb afb${compact ? " afb-compact" : ""}`}>
      <div className="fb-row">
        {label && <span className="muted small afb-label">{label}</span>}
        <button type="button" className={`fb-btn${fb?.rating === "up" ? " is-on" : ""}`} aria-pressed={fb?.rating === "up"}
                aria-label="Good" title="Good" onClick={() => choose("up")}>
          <Thumb />
        </button>
        <button type="button" className={`fb-btn${fb?.rating === "down" ? " is-on is-down" : ""}`} aria-pressed={fb?.rating === "down"}
                aria-label="Not good" title="Not good" onClick={() => choose("down")}>
          <Thumb down />
        </button>
        {thanks && <span className="mono muted small">Thanks, saved.</span>}
        {error && <span className="error-text small">Could not save that. Try again.</span>}
      </div>
      {!open && fb && (fb.comment || fb.reasons.length > 0) && (
        <div className="afb-saved">
          <span className="mono muted small">Your review</span>
          {fb.reasons.length > 0 && (
            <div className="fb-chips">
              {fb.reasons.map((id) => <span key={id} className="fb-chip is-on">{options.find((o) => o.id === id)?.label ?? id}</span>)}
            </div>
          )}
          {fb.comment && <p className="afb-saved-text">{fb.comment}</p>}
          <button type="button" className="link-btn small" onClick={() => { setReasons(fb.reasons); setComment(fb.comment ?? ""); setOpen(true); }}>Edit</button>
        </div>
      )}
      {open && fb && (
        <div className="fb-why afb-why" ref={whyRef}>
          <p className="mono muted small">{fb.rating === "up" ? "What worked well? (optional)" : "What was wrong? (optional)"}</p>
          {fb.rating === "down" && <div className="fb-chips">
            {options.map((r) => (
              <button key={r.id} type="button" className={`fb-chip${reasons.includes(r.id) ? " is-on" : ""}`} aria-pressed={reasons.includes(r.id)}
                      onClick={() => setReasons((list) => (list.includes(r.id) ? list.filter((x) => x !== r.id) : [...list, r.id]))}>
                {r.label}
              </button>
            ))}
          </div>}
          <textarea className="input fb-note" rows={2} maxLength={1000} value={comment} onChange={(e) => setComment(e.target.value)}
                    placeholder={fb.rating === "up" ? "Tell us more, like what you liked" : "Tell us more, like what it should have been"}
                    aria-label={fb.rating === "up" ? "What worked well" : "What was wrong"} />
          <div className="row end">
            <button type="button" className="btn btn-small" onClick={() => setOpen(false)}>Skip</button>
            <button type="button" className="btn btn-small btn-primary" onClick={async () => {
              await save(fb.rating, fb.rating === "down" ? reasons : [], comment.trim());
              setOpen(false);
              setThanks(true);
            }}>Send</button>
          </div>
        </div>
      )}
    </div>
  );
}