File size: 7,846 Bytes
0649f1b
 
 
 
66de9c8
0649f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
0649f1b
66de9c8
0649f1b
 
 
 
 
 
 
 
 
 
 
66de9c8
 
0649f1b
66de9c8
0649f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
 
 
0649f1b
 
 
 
 
 
 
 
 
 
 
 
66de9c8
0649f1b
 
 
 
 
 
 
 
 
66de9c8
0649f1b
 
66de9c8
 
 
 
0649f1b
 
66de9c8
 
0649f1b
 
66de9c8
 
0649f1b
 
 
66de9c8
0649f1b
 
 
 
66de9c8
 
0649f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
0649f1b
 
 
 
 
 
 
 
 
 
 
66de9c8
0649f1b
66de9c8
0649f1b
 
66de9c8
 
0649f1b
 
66de9c8
0649f1b
 
 
 
 
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
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { artifactsApi, notebooksApi, reportsApi } from "../api/endpoints";
import type { Artifact, Citation, ReportSuggestion } from "../api/types";
import { ArtifactFeedback } from "../components/ArtifactFeedback";
import { ReportAddDialog } from "../components/ReportAddDialog";
import { ShareDialog } from "../components/ShareDialog";
import { Reader } from "../components/Reader";
import { reportMarkdown, ReportView } from "../components/ReportView";
import { ConfirmButton, errorMessage, JobProgress, Loading } from "../components/ui";
import { useJob } from "../hooks/useJob";

/** One report, full page: /app/reports/:id. */
export default function ReportPage() {
  const { id = "" } = useParams();
  const navigate = useNavigate();
  const [artifact, setArtifact] = useState<Artifact | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [adding, setAdding] = useState<{ suggestion: ReportSuggestion; existing: Artifact[]; loading: boolean } | null>(null);
  const [sharing, setSharing] = useState(false);
  const [addFailed, setAddFailed] = useState<string | null>(null);
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
  const [removingId, setRemovingId] = useState<string | null>(null);

  const load = useCallback(() => artifactsApi.get(id).then(setArtifact, () => setError("Report not found.")), [id]);
  useEffect(() => {
    void load();
  }, [load]);
  const [addingId, setAddingId] = useState<string | null>(null); // the suggestion being built (its card shows a loader)
  const job = useJob(artifact?.job, () => {
    void load();
    setAddingId(null);
  });

  // If the live updates drop, the loader still ends once the job's own state is final. Only the job that adds a visual counts: the
  // report's own, long finished job is what the page still holds in the moment after Add is clicked.
  useEffect(() => {
    if (addingId && job && job.kind === "report_block" && job.id === artifact?.job?.id && (job.status === "done" || job.status === "failed")) {
      setAddingId(null);
      void load();
    }
  }, [job, addingId, artifact?.job?.id, load]);

  // An infographic added to the report is drawn after the report page shows it: look again until it is ready.
  const drawing = (artifact?.content.blocks ?? []).some((b: { type: string; status?: string }) =>
    b.type === "infographic" && ["pending", "generating", "rendering"].includes(b.status ?? ""));
  useEffect(() => {
    if (!drawing) return;
    const t = setTimeout(() => void load(), 2500);
    return () => clearTimeout(t);
  }, [drawing, artifact, load]);

  if (error) return <p className="error-text">{error}</p>;
  if (!artifact) return <Loading label="Opening report" />;
  const running = job && (job.status === "queued" || job.status === "running");
  const adding_ = !!addingId || !!(running && job?.kind === "report_block"); // a visual is being added: one at a time
  // Which suggestion's card shows the loader: the one just clicked, or, after a reload or a dropped update, the one the running job is adding.
  const buildingId = addingId ?? (running && job?.kind === "report_block" ? (job.params.suggestion_id as string | undefined) ?? null : null);
  const blockFailed = job?.kind === "report_block" && job.status === "failed" ? job.error : null;
  const back = artifact.notebook_id ? `/app/notebooks/${artifact.notebook_id}` : "/app/archive";

  const download = () => {
    const url = URL.createObjectURL(new Blob([reportMarkdown(artifact.content)], { type: "text/markdown" }));
    const a = document.createElement("a");
    a.href = url;
    a.download = `${artifact.title.slice(0, 60)}.md`;
    a.click();
    URL.revokeObjectURL(url);
  };
  const startAdd = async (s: ReportSuggestion) => {
    setAddFailed(null);
    setAdding({ suggestion: s, existing: [], loading: true });
    // The ones already made from this notebook (or, for a report with no notebook, anywhere), to offer as an alternative
    // to making a new one.
    const all = artifact.notebook_id
      ? await notebooksApi.artifacts(artifact.notebook_id).catch(() => [] as Artifact[])
      : await artifactsApi.list({ type: s.kind, status: "ready", limit: 50 }).then((p) => p.items).catch(() => [] as Artifact[]);
    setAdding((cur) => cur && cur.suggestion.id === s.id
      ? { ...cur, loading: false, existing: all.filter((a) => a.type === s.kind && a.status === "ready") } : cur);
  };
  // Add closes the dialog at once and the suggestion's own card shows the loader, down in the report where the visual will appear.
  const add = async (existingId: string | null, theme?: string) => {
    if (!adding) return;
    const { suggestion } = adding;
    setAddFailed(null);
    setAddingId(suggestion.id);
    setAdding(null);
    try {
      setArtifact(await reportsApi.addBlock(artifact.id, {
        kind: suggestion.kind, after_block_id: suggestion.after_block_id, suggestion_id: suggestion.id,
        brief: suggestion.brief, existing_artifact_id: existingId ?? undefined, theme,
      }));
    } catch (e) {
      setAddingId(null);
      setAddFailed(errorMessage(e));
    }
  };
  const remove = async (blockId: string) => {
    setRemovingId(blockId);
    try {
      setArtifact(await reportsApi.removeBlock(artifact.id, blockId));
    } catch (e) {
      setError(errorMessage(e));
    } finally {
      setRemovingId(null);
    }
  };

  const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end });

  return (
    <div className="page-wrap rp-page">
      <Link to={back} className="vw-back">Back</Link>
      <header className="rp-head">
        <p className="eyebrow">{artifact.content.format === "interactive" ? "Interactive report" : "Report"}</p>
        <h1>{artifact.title}</h1>
        {artifact.status === "ready" && (
          <div className="row">
            <button type="button" className="btn btn-small btn-primary" onClick={() => setSharing(true)}>Share</button>
            <button type="button" className="btn btn-small" onClick={download}>Download Markdown</button>
            <ArtifactFeedback artifact={artifact} label="How is this report?" compact />
            <ConfirmButton onConfirm={async () => {
              try {
                await artifactsApi.remove(artifact.id);
                navigate(back);
              } catch (e) {
                setError(errorMessage(e));
              }
            }}>Delete</ConfirmButton>
          </div>
        )}
      </header>
      {running && job && job.kind !== "report_block" && <JobProgress job={job} />}
      {artifact.status === "failed" && <p className="error-text">{(artifact.content.error as string | undefined) ?? job?.error ?? "The report could not be made."}</p>}
      {(addFailed ?? blockFailed) && <p className="error-text">{addFailed ?? blockFailed}</p>}
      {artifact.status === "ready" && (
        <ReportView content={artifact.content} artifactId={artifact.id} onCite={cite}
                    editor={{ onAdd: startAdd, onRemove: remove, busy: adding_, adding: buildingId,
                             addingMessage: job?.kind === "report_block" ? job.message : null, removing: removingId }} />
      )}
      {sharing && <ShareDialog artifactId={artifact.id} onClose={() => setSharing(false)} />}
      {adding && <ReportAddDialog suggestion={adding.suggestion} existing={adding.existing} loading={adding.loading} busy={false} error={null}
                                  onClose={() => setAdding(null)} onAdd={add} />}
      {reading && <Reader documentId={reading.id} highlight={{ start: reading.start, end: reading.end }} onClose={() => setReading(null)} />}
    </div>
  );
}