diff --git a/.gitignore b/.gitignore index b31a2938b..f0a8d416e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ prod.env xids.csv preprod.env +.venv diff --git a/client-report/jest.config.js b/client-report/jest.config.js index 2e5dfe061..d44176cd8 100644 --- a/client-report/jest.config.js +++ b/client-report/jest.config.js @@ -193,6 +193,7 @@ const config = { // Whether to use watchman for file crawling // watchman: true, + setupFilesAfterEnv: ["/setupTests.js"] }; module.exports = config; diff --git a/client-report/package-lock.json b/client-report/package-lock.json index b7fb0457b..53bfb838f 100644 --- a/client-report/package-lock.json +++ b/client-report/package-lock.json @@ -33,6 +33,7 @@ "@babel/preset-react": "~7.18.6", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "babel-jest": "^29.7.0", "babel-loader": "~9.1.2", "compression-webpack-plugin": "^10.0.0", @@ -45,6 +46,7 @@ "html-webpack-plugin": "^5.5.3", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "mini-css-extract-plugin": "^2.7.6", "webpack": "~5.88.0", "webpack-cli": "~5.1.4", @@ -2611,6 +2613,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4457,6 +4473,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8278,6 +8304,17 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -9380,6 +9417,52 @@ "tslib": "^2.0.3" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -10170,6 +10253,13 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", "integrity": "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==" }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/client-report/package.json b/client-report/package.json index 6eeaae69e..e6d63206c 100644 --- a/client-report/package.json +++ b/client-report/package.json @@ -20,6 +20,7 @@ "@babel/preset-react": "~7.18.6", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "babel-jest": "^29.7.0", "babel-loader": "~9.1.2", "compression-webpack-plugin": "^10.0.0", @@ -32,6 +33,7 @@ "html-webpack-plugin": "^5.5.3", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "mini-css-extract-plugin": "^2.7.6", "webpack": "~5.88.0", "webpack-cli": "~5.1.4", diff --git a/client-report/setupTests.js b/client-report/setupTests.js new file mode 100644 index 000000000..247eb4e46 --- /dev/null +++ b/client-report/setupTests.js @@ -0,0 +1,5 @@ +// import fetch from 'node-fetch'; +import '@testing-library/jest-dom'; +import fetchMock from 'jest-fetch-mock'; + +fetchMock.enableMocks(); \ No newline at end of file diff --git a/client-report/src/components/RawDataExport.js b/client-report/src/components/RawDataExport.jsx similarity index 100% rename from client-report/src/components/RawDataExport.js rename to client-report/src/components/RawDataExport.jsx diff --git a/client-report/src/components/app.js b/client-report/src/components/app.js deleted file mode 100644 index 76f175e75..000000000 --- a/client-report/src/components/app.js +++ /dev/null @@ -1,689 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; -// import { connect } from "react-redux"; -// import probabilities from "../sampleData/probabilities"; -// import covariance from "../sampleData/covariance"; -// import correlation from "../sampleData/correlation"; -// import correlationHClust from "../sampleData/correlationHClust" -import _ from "lodash"; - -import * as globals from "./globals"; -import URLs from "../util/url"; -import DataUtils from "../util/dataUtils"; -// import Matrix from "./correlationMatrix/matrix"; -import Heading from "./framework/heading"; -import Footer from "./framework/Footer"; -import Overview from "./overview"; -import MajorityStrict from "./lists/majorityStrict"; -import Uncertainty from "./lists/uncertainty"; -import UncertaintyNarrative from "./lists/uncertaintyNarrative"; -import AllCommentsModeratedIn from "./lists/allCommentsModeratedIn"; -import ParticipantGroups from "./lists/participantGroups"; -// import CommentsGraph from "./commentsGraph/commentsGraph"; -import ParticipantsGraph from "./participantsGraph/participantsGraph"; -// import BoxPlot from "./boxPlot/boxPlot"; -import Beeswarm from "./beeswarm/beeswarm.jsx"; -import Controls from "./controls/controls"; - -import net from "../util/net"; - -import $ from "jquery"; - -import Narrative from "./narrative"; -import ConsensusNarrative from "./lists/consensusNarrative"; -import RawDataExport from "./RawDataExport"; - -var pathname = window.location.pathname; // "/report/2arcefpshi" -var report_id = pathname.split("/")[2]; - -function assertExists(obj, key) { - if (typeof obj[key] === "undefined") { - console.error("assertExists failed. Missing: ", key); - } -} - -class App extends React.Component { - constructor(props) { - super(props); - this.state = { - loading: true, - consensus: null, - comments: null, - participants: null, - conversation: null, - groupDemographics: null, - colorBlindMode: false, - model: "claude", - isNarrativeReport: window.location.pathname.split("/")[1] === "narrativeReport", - dimensions: { - width: window.innerWidth, - height: window.innerHeight, - }, - shouldPoll: false, - voteColors: { - agree: globals.brandColors.agree, - disagree: globals.brandColors.disagree, - pass: globals.brandColors.pass, - }, - narrative: { - }, - }; - } - - async componentDidUpdate() { - if ( - window.location.pathname.split("/")[1] === "narrativeReport" && - this.state.isNarrativeReport !== true - ) { - this.setState({ isNarrativeReport: true }); - } else if ( - this.state.isNarrativeReport && - window.location.pathname.split("/")[1] !== "narrativeReport" - ) { - this.setState({ isNarrativeReport: false }); - } - } - - getMath(conversation_id) { - return net - .polisGet("/api/v3/math/pca2", { - lastVoteTimestamp: 0, - conversation_id: conversation_id, - }) - .then((data) => { - if (!data) { - return {}; - } - return data; - }); - } - - getComments(conversation_id, isStrictMod) { - return net.polisGet("/api/v3/comments", { - conversation_id: conversation_id, - report_id: report_id, - moderation: true, - mod_gt: isStrictMod ? 0 : -1, - //include_social: true, - //include_demographics: true, - include_voting_patterns: true, - }); - } - - getParticipantsOfInterest(conversation_id) { - return net.polisGet("/api/v3/ptptois", { - conversation_id: conversation_id, - }); - } - getConversation(conversation_id) { - return net.polisGet("/api/v3/conversations", { - conversation_id: conversation_id, - }); - } - - async getNarrative(report_id) { - const urlPrefix = URLs.urlPrefix; - const response = await fetch(`${urlPrefix}api/v3/reportNarrative?report_id=${report_id}`, { - credentials: "include", - method: "get", - headers: { - Accept: "application/json, text/plain, */*", - "Content-Type": "application/json", - }, - }); - if (!response.ok || !response.body) { - throw response.statusText; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const loopRunner = true; - - while (loopRunner) { // streaming response - the loop will run indefinetly until the response ends - this is a streaming function to improve UX and prevent cloud runners (like heroku) from terminating a long running http request - const { value, done } = await reader.read(); - if (done) { - break; - } - const decodedChunk = decoder.decode(value, { stream: true }); - - if (!decodedChunk.includes('POLIS-PING:')) this.setState(state => ({ narrative: Object.assign(state.narrative, JSON.parse(decodedChunk)) })) - } - } - - getReport(report_id) { - return net - .polisGet("/api/v3/reports", { - report_id: report_id, - }) - .then((reports) => { - if (reports.length) { - return reports[0]; - } - return null; - }); - } - getGroupDemographics(conversation_id) { - return net.polisGet("/api/v3/group_demographics", { - conversation_id: conversation_id, - report_id: report_id, - }); - } - - getConversationStats(conversation_id) { - return net.polisGet("/api/v3/conversationStats", { - conversation_id: conversation_id, - report_id: report_id, - }); - } - - getCorrelationMatrix(math_tick) { - const attemptResponse = net.polisGet("/api/v3/math/correlationMatrix", { - math_tick: math_tick, - report_id: report_id, - }); - - return new Promise((resolve, reject) => { - attemptResponse.then( - (response) => { - if (response.status && response.status === "pending") { - this.corMatRetries = _.isNumber(this.corMatRetries) ? this.corMatRetries + 1 : 1; - setTimeout( - () => { - this.getCorrelationMatrix(math_tick).then(resolve, reject); - }, - this.corMatRetries < 10 ? 200 : 3000 - ); // try to get a quick response, but don't keep polling at that rate for more than 10 seconds. - } else if ( - globals.enableMatrix && - response && - response.status === "polis_report_needs_comment_selection" - ) { - this.setState({ - errorText: "Select some comments", - }); - reject("Currently, No comments are selected for display in the matrix."); - } else { - resolve(response); - } - }, - (err) => { - reject(err); - } - ); - }); - } - - getData() { - const reportPromise = this.getReport(report_id); - const mathPromise = reportPromise.then((report) => { - return this.getMath(report.conversation_id); - }); - const commentsPromise = reportPromise.then((report) => { - return conversationPromise.then((conv) => { - return this.getComments(report.conversation_id, conv.strict_moderation); - }); - }); - const groupDemographicsPromise = reportPromise.then((report) => { - return this.getGroupDemographics(report.conversation_id); - }); - //const conversationStatsPromise = reportPromise.then((report) => { - //return this.getConversationStats(report.conversation_id) - //}); - const participantsOfInterestPromise = reportPromise.then((report) => { - return this.getParticipantsOfInterest(report.conversation_id); - }); - const matrixPromise = globals.enableMatrix - ? mathPromise.then((math) => { - const math_tick = math.math_tick; - return this.getCorrelationMatrix(math_tick); - }) - : Promise.resolve(); - const conversationPromise = reportPromise.then((report) => { - return this.getConversation(report.conversation_id); - }); - - const narrativePromise = reportPromise.then((report) => { - if (this.state.isNarrativeReport) this.getNarrative(report.report_id); - }); - - Promise.all([ - reportPromise, - mathPromise, - commentsPromise, - groupDemographicsPromise, - participantsOfInterestPromise, - matrixPromise, - conversationPromise, - //conversationStatsPromise, - narrativePromise, - ]) - .then((a) => { - let [ - report, - mathResult, - comments, - groupDemographics, - participants, - correlationHClust, - conversation, - narrative, - //conversationstats, - ] = a; - - assertExists(mathResult, "base-clusters"); - assertExists(mathResult, "consensus"); - assertExists(mathResult, "group-aware-consensus"); - assertExists(mathResult, "group-clusters"); - assertExists(mathResult, "group-votes"); - assertExists(mathResult, "n-cmts"); - assertExists(mathResult, "repness"); - assertExists(mathResult, "pca"); - assertExists(mathResult, "tids"); - assertExists(mathResult, "user-vote-counts"); - assertExists(mathResult, "votes-base"); - assertExists(mathResult.pca, "center"); - assertExists(mathResult.pca, "comment-extremity"); - assertExists(mathResult.pca, "comment-projection"); - assertExists(mathResult.pca, "comps"); - - let indexToTid = mathResult.tids; - - // # ptpts that voted - var ptptCountTotal = conversation.participant_count; - - // # ptpts that voted enough to be included in math - var ptptCount = 0; - _.each(mathResult["group-votes"], (val /*, key*/) => { - ptptCount += val["n-members"]; - }); - - var badTids = {}; - var filteredTids = {}; - var filteredProbabilities = {}; - - // prep Correlation matrix. - if (globals.enableMatrix) { - var probabilities = correlationHClust.matrix; - var tids = correlationHClust.comments; - for (let row = 0; row < probabilities.length; row++) { - if (probabilities[row][0] === "NaN") { - let tid = correlationHClust.comments[row]; - badTids[tid] = true; - } - } - filteredProbabilities = probabilities - .map((row) => { - return row.filter((cell, colNum) => { - let colTid = correlationHClust.comments[colNum]; - return badTids[colTid] !== true; - }); - }) - .filter((row, rowNum) => { - let rowTid = correlationHClust.comments[rowNum]; - return badTids[rowTid] !== true; - }); - filteredTids = tids.filter((tid /*, index*/) => { - return badTids[tid] !== true; - }); - } - - var maxTid = -1; - for (let i = 0; i < comments.length; i++) { - if (comments[i].tid > maxTid) { - maxTid = comments[i].tid; - } - } - var tidWidth = ("" + maxTid).length; - - function pad(n, width, z) { - z = z || "0"; - n = n + ""; - return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n; - } - function formatTid(tid) { - // let padded = "" + tid; - // return '#' + pad(""+tid, tidWidth); - return pad("" + tid, tidWidth); - } - - let repfulAgreeTidsByGroup = {}; - let repfulDisageeTidsByGroup = {}; - if (mathResult.repness) { - _.each(mathResult.repness, (entries, gid) => { - entries.forEach((entry) => { - if (entry["repful-for"] === "agree") { - repfulAgreeTidsByGroup[gid] = repfulAgreeTidsByGroup[gid] || []; - repfulAgreeTidsByGroup[gid].push(entry.tid); - } else if (entry["repful-for"] === "disagree") { - repfulDisageeTidsByGroup[gid] = repfulDisageeTidsByGroup[gid] || []; - repfulDisageeTidsByGroup[gid].push(entry.tid); - } - }); - }); - } - - // ====== REMEMBER: gid's start at zero, (0, 1, 2) but we show them as group 1, 2, 3 in participation view ====== - let groupNames = {}; - for (let i = 0; i <= 9; i++) { - let label = report["label_group_" + i]; - if (label) { - groupNames[i] = label; - } - } - - let uncertainty = []; - - // let maxCount = _.reduce(comments, (memo, c) => { return Math.max(c.count, memo);}, 1); - comments.map((c) => { - var unc = c.pass_count / c.count; - if (unc > 0.3) { - c.unc = unc; - uncertainty.push(c); - } - }); - uncertainty.sort((a, b) => { - return b.unc * b.unc * b.pass_count - a.unc * a.unc * a.pass_count; - }); - uncertainty = uncertainty.slice(0, 5); - - let extremity = {}; - _.each(mathResult.pca["comment-extremity"], function (e, index) { - extremity[indexToTid[index]] = e; - }); - - var uniqueCommenters = {}; - var voteTotals = DataUtils.getVoteTotals(mathResult); - comments = comments.map((c) => { - c["group-aware-consensus"] = mathResult["group-aware-consensus"][c.tid]; - uniqueCommenters[c.pid] = 1; - c = Object.assign(c, voteTotals[c.tid]); - return c; - }); - var numUniqueCommenters = _.keys(uniqueCommenters).length; - var totalVotes = _.reduce( - _.values(mathResult["user-vote-counts"]), - function (memo, num) { - return memo + num; - }, - 0 - ); - const computedStats = { - votesPerVoterAvg: totalVotes / ptptCountTotal, - commentsPerCommenterAvg: comments.length / numUniqueCommenters, - }; - - this.setState({ - loading: false, - math: mathResult, - consensus: mathResult.consensus, - extremity: extremity, - uncertainty: uncertainty.map((c) => { - return c.tid; - }), - comments: comments, - demographics: groupDemographics, - participants: participants, - conversation: conversation, - ptptCount: ptptCount, - ptptCountTotal: ptptCountTotal, - filteredCorrelationMatrix: filteredProbabilities, - filteredCorrelationTids: filteredTids, - badTids: badTids, - groupNames: groupNames, - repfulAgreeTidsByGroup: repfulAgreeTidsByGroup, - repfulDisageeTidsByGroup: repfulDisageeTidsByGroup, - formatTid: formatTid, - report: report, - // narrative: this.state.isNarrativeReport ? narrative : undefined, - //conversationStats: conversationstats, - computedStats: computedStats, - nothingToShow: !comments.length || !groupDemographics.length, - }); - }) - .catch((err) => { - this.setState({ - error: true, - errorText: String(err), - }); - }); - } - - async UNSAFE_componentWillMount() { - this.getData(); - - setInterval(() => { - if (this.state.shouldPoll) { - this.getData(); - } - }, 20 * 1000); - - window.addEventListener( - "resize", - _.throttle(() => { - this.setState({ - dimensions: { - width: window.innerWidth, - height: window.innerHeight, - }, - }); - }, 500) - ); - } - - onAutoRefreshEnabled() { - this.setState({ - shouldPoll: true, - }); - } - - onAutoRefreshDisabled() { - this.setState({ - shouldPoll: false, - }); - } - - handleColorblindModeClick() { - var colorBlind = !this.state.colorBlindMode; - if (colorBlind) { - this.setState({ - colorBlindMode: colorBlind, - voteColors: Object.assign(this.state.voteColors, { - agree: globals.brandColors.agreeColorblind, - }), - }); - } else { - this.setState({ - colorBlindMode: colorBlind, - voteColors: Object.assign(this.state.voteColors, { - agree: globals.brandColors.agree, - }), - }); - } - } - - render() { - if (this.state.error) { - return ( -
-
Error Loading
-
{this.state.errorText}
-
- ); - } - if (this.state.nothingToShow) { - return ( -
-
Nothing to show yet
-
- ); - } - if (this.state.loading) { - return ( -
-
Loading ...
-
- ); - } - - return ( -
- -
- - - {/* This may eventually need to go back in below */} - {/* stats={this.state.conversationStats} */} - - - {!this.state.isNarrativeReport && ( - - )} - - {this.state.isNarrativeReport ? ( - <> - -

Current Model: {this.state.model}

- - - - ) : ( - <> - - {/* -

Consensus

-

Inclusive Majority

- */} - - - - - {/* {false ? : null} - {globals.enableMatrix && false ? : ""} */} - - {/* */} - - - )} -
-
-
- ); - } -} - -export default App; - -window.$ = $; diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx new file mode 100644 index 000000000..675e74446 --- /dev/null +++ b/client-report/src/components/app.jsx @@ -0,0 +1,686 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React, { useState, useEffect } from "react"; + +import * as globals from "./globals.js"; +import URLs from "../util/url.js"; +import DataUtils from "../util/dataUtils.js"; +import Heading from "./framework/heading.jsx"; +import Footer from "./framework/Footer.jsx"; +import Overview from "./overview.jsx"; +import MajorityStrict from "./lists/majorityStrict.jsx"; +import Uncertainty from "./lists/uncertainty.jsx"; +import UncertaintyNarrative from "./lists/uncertaintyNarrative.jsx"; +import AllCommentsModeratedIn from "./lists/allCommentsModeratedIn.jsx"; +import ParticipantGroups from "./lists/participantGroups.jsx"; +import ParticipantsGraph from "./participantsGraph/participantsGraph.jsx"; +import Beeswarm from "./beeswarm/beeswarm.jsx"; +import Controls from "./controls/controls.jsx"; +import net from "../util/net.js"; +import ConsensusNarrative from "./lists/consensusNarrative.jsx"; +import RawDataExport from "./RawDataExport.jsx"; + +const pathname = window.location.pathname; // "/report/2arcefpshi" +const report_id = pathname.split("/")[2]; + +function assertExists(obj, key) { + if (typeof obj[key] === "undefined") { + console.error("assertExists failed. Missing: ", key); + } +} + +const App = (props) => { + const [loading, setLoading] = useState(true); + const [consensus, setConsensus] = useState(null); + const [math, setMath] = useState(null); + const [comments, setComments] = useState(null); + const [participants, setParticipants] = useState(null); + const [conversation, setConversation] = useState(null); + const [groupDemographics, setGroupDemographics] = useState(null); + const [colorBlindMode, setColorBlindMode] = useState(false); + const [model, setModel] = useState("claude"); + const [isNarrativeReport, setIsNarrativeReport] = useState(window.location.pathname.split("/")[1] === "narrativeReport"); + const [dimensions, setDimensions] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + const [shouldPoll, setShouldPoll] = useState(false); + const [voteColors, setVoteColors] = useState({ + agree: globals.brandColors.agree, + disagree: globals.brandColors.disagree, + pass: globals.brandColors.pass, + }); + const [narrative, setNarrative] = useState(null); + const [errorText, setErrorText] = useState(null); + const [extremity, setExtremity] = useState(null); + const [uncertainty, setUncertainty] = useState(null); + const [ptptCount, setPtptCount] = useState(null); + const [ptptCountTotal, SetPtptCountTotal] = useState(null); + const [filteredCorrelationMatrix, setFilteredCorrelationMatrix] = useState(null); + const [filteredCorrelationTids, setFilteredCorrelationTids] = useState(null); + const [badTids, setBadTids] = useState(null); + const [groupNames, setGroupNames] = useState(null); + const [repfulAgreeTidsByGroup, setRepfulAgreeTidsByGroup] = useState(null); + const[repfulDisageeTidsByGroup, setRepfulDisageeTidsByGroup] = useState(null); + const [formatTid, setFormatTid] = useState(() => v => v); + const [report, setReport] = useState(null); + const [computedStats, setComputedStats] = useState(null); + const [nothingToShow, setNothingToShow] = useState(true); + const [hasError, setError] = useState(false); + const [parsedNarrativeUncertainty, setParsedNarrativeUncertainty] = useState(null); + const [parsedNarrativeConsensus, setParsedNarrativeConsensus] = useState(null); + + let corMatRetries; + + useEffect(() => { + if ( + window.location.pathname.split("/")[1] === "narrativeReport" && + isNarrativeReport !== true + ) { + setIsNarrativeReport(true); + } else if ( + isNarrativeReport && + window.location.pathname.split("/")[1] !== "narrativeReport" + ) { + setIsNarrativeReport(false); + } + }, [window.location?.pathname]); + + useEffect(() => { + if (narrative?.group_informed_consensus) { + setParsedNarrativeConsensus(narrative.group_informed_consensus); + } + if (narrative?.uncertainty) { + setParsedNarrativeUncertainty(narrative.uncertainty); + } + }, [narrative?.uncertainty, narrative?.group_informed_consensus, JSON.stringify(narrative)]) + + const getMath = async (conversation_id) => { + return net + .polisGet("/api/v3/math/pca2", { + lastVoteTimestamp: 0, + conversation_id: conversation_id, + }) + .then((data) => { + if (!data) { + return {}; + } + return data; + }); + } + + const getComments = (conversation_id, isStrictMod) => { + return net.polisGet("/api/v3/comments", { + conversation_id: conversation_id, + report_id: report_id, + moderation: true, + mod_gt: isStrictMod ? 0 : -1, + //include_social: true, + //include_demographics: true, + include_voting_patterns: true, + }); + } + + const getParticipantsOfInterest = (conversation_id) => { + return net.polisGet("/api/v3/ptptois", { + conversation_id: conversation_id, + }); + } + const getConversation = (conversation_id) => { + return net.polisGet("/api/v3/conversations", { + conversation_id: conversation_id, + }); + } + + const getNarrative = async (report_id) => { + const urlPrefix = URLs.urlPrefix; + const response = await fetch(`${urlPrefix}api/v3/reportNarrative?report_id=${report_id}`, { + credentials: "include", + method: "get", + headers: { + Accept: "application/json, text/plain, */*", + "Content-Type": "application/json", + }, + }); + if (!response.ok || !response.body) { + throw response.statusText; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const loopRunner = true; + + while (loopRunner) { // streaming response - the loop will run indefinetly until the response ends - this is a streaming function to improve UX and prevent cloud runners (like heroku) from terminating a long running http request + const { value, done } = await reader.read(); + if (done) { + break; + } + const decodedChunk = decoder.decode(value, { stream: true }); + + if (!decodedChunk.includes('POLIS-PING:')) { + const o = narrative || {}; + const c = JSON.parse(decodedChunk); + setNarrative({...o, ...c}); + } + } + } + + const getReport = (report_id) => { + return net + .polisGet("/api/v3/reports", { + report_id: report_id, + }) + .then((reports) => { + if (reports.length) { + return reports[0]; + } + return null; + }); + } + const getGroupDemographics = (conversation_id) => { + return net.polisGet("/api/v3/group_demographics", { + conversation_id: conversation_id, + report_id: report_id, + }); + } + + const getConversationStats = (conversation_id) => { + return net.polisGet("/api/v3/conversationStats", { + conversation_id: conversation_id, + report_id: report_id, + }); + } + + const getCorrelationMatrix = (math_tick) => { + const attemptResponse = net.polisGet("/api/v3/math/correlationMatrix", { + math_tick: math_tick, + report_id: report_id, + }); + + return new Promise((resolve, reject) => { + attemptResponse.then( + (response) => { + if (response.status && response.status === "pending") { + if (typeof corMatRetries === 'number') { + corMatRetries = corMatRetries + 1; + } else { + corMatRetries = 1; + } + setTimeout( + () => { + getCorrelationMatrix(math_tick).then(resolve, reject); + }, + corMatRetries < 10 ? 200 : 3000 + ); // try to get a quick response, but don't keep polling at that rate for more than 10 seconds. + } else if ( + globals.enableMatrix && + response && + response.status === "polis_report_needs_comment_selection" + ) { + setErrorText("Select some comments"); + reject("Currently, No comments are selected for display in the matrix."); + } else { + resolve(response); + } + }, + (err) => { + reject(err); + } + ); + }); + } + + const getData = async () => { + const reportPromise = getReport(report_id); + const mathPromise = reportPromise.then((report) => { + return getMath(report.conversation_id); + }); + const commentsPromise = reportPromise.then((report) => { + return conversationPromise.then((conv) => { + return getComments(report.conversation_id, conv.strict_moderation); + }); + }); + const groupDemographicsPromise = reportPromise.then((report) => { + return getGroupDemographics(report.conversation_id); + }); + const participantsOfInterestPromise = reportPromise.then((report) => { + return getParticipantsOfInterest(report.conversation_id); + }); + const matrixPromise = globals.enableMatrix + ? mathPromise.then((math) => { + const math_tick = math.math_tick; + return getCorrelationMatrix(math_tick); + }) + : Promise.resolve(); + const conversationPromise = reportPromise.then((report) => { + return getConversation(report.conversation_id); + }); + + const narrativePromise = reportPromise.then((report) => { + if (isNarrativeReport) getNarrative(report.report_id); + }); + + Promise.all([ + reportPromise, + mathPromise, + commentsPromise, + groupDemographicsPromise, + participantsOfInterestPromise, + matrixPromise, + conversationPromise, + narrativePromise, + ]) + .then((a) => { + let [ + _report, + mathResult, + _comments, + _groupDemographics, + _participants, + correlationHClust, + _conversation, + narrative, + ] = a; + + assertExists(mathResult, "base-clusters"); + assertExists(mathResult, "consensus"); + assertExists(mathResult, "group-aware-consensus"); + assertExists(mathResult, "group-clusters"); + assertExists(mathResult, "group-votes"); + assertExists(mathResult, "n-cmts"); + assertExists(mathResult, "repness"); + assertExists(mathResult, "pca"); + assertExists(mathResult, "tids"); + assertExists(mathResult, "user-vote-counts"); + assertExists(mathResult, "votes-base"); + assertExists(mathResult.pca, "center"); + assertExists(mathResult.pca, "comment-extremity"); + assertExists(mathResult.pca, "comment-projection"); + assertExists(mathResult.pca, "comps"); + + let indexToTid = mathResult.tids; + + // # ptpts that voted + var _ptptCountTotal = _conversation.participant_count; + + // # ptpts that voted enough to be included in math + var _ptptCount = 0; + const groupVotes = mathResult["group-votes"]; + for (const key in groupVotes) { + const val = groupVotes[key]; + _ptptCount += val["n-members"]; + } + + var _badTids = {}; + var _filteredTids = {}; + var _filteredProbabilities = {}; + + // prep Correlation matrix. + if (globals.enableMatrix && correlationHClust) { + var probabilities = correlationHClust.matrix; + var tids = correlationHClust.comments; + for (let row = 0; row < probabilities.length; row++) { + if (probabilities[row][0] === "NaN") { + let tid = correlationHClust.comments[row]; + _badTids[tid] = true; + } + } + _filteredProbabilities = probabilities + .map((row) => { + return row.filter((cell, colNum) => { + let colTid = correlationHClust.comments[colNum]; + return _badTids[colTid] !== true; + }); + }) + .filter((row, rowNum) => { + let rowTid = correlationHClust.comments[rowNum]; + return _badTids[rowTid] !== true; + }); + _filteredTids = tids.filter((tid /*, index*/) => { + return _badTids[tid] !== true; + }); + } + + var maxTid = -1; + for (let i = 0; i < _comments.length; i++) { + if (_comments[i].tid > maxTid) { + maxTid = _comments[i].tid; + } + } + var tidWidth = ("" + maxTid).length; + + function pad(n, width, z) { + z = z || "0"; + n = n + ""; + return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n; + } + function _formatTid(tid) { + return pad("" + tid, tidWidth); + } + + const _repfulAgreeTidsByGroup = {}; + const _repfulDisageeTidsByGroup = {}; + + if (mathResult.repness) { + for (const gid in mathResult.repness) { + const entries = mathResult.repness[gid]; + + entries.forEach((entry) => { + if (entry["repful-for"] === "agree") { + _repfulAgreeTidsByGroup[gid] = _repfulAgreeTidsByGroup[gid] || []; + _repfulAgreeTidsByGroup[gid].push(entry.tid); + } else if (entry["repful-for"] === "disagree") { + _repfulDisageeTidsByGroup[gid] = _repfulDisageeTidsByGroup[gid] || []; + _repfulDisageeTidsByGroup[gid].push(entry.tid); + } + }); + } + } + + // ====== REMEMBER: gid's start at zero, (0, 1, 2) but we show them as group 1, 2, 3 in participation view ====== + let _groupNames = {}; + for (let i = 0; i <= 9; i++) { + let label = _report["label_group_" + i]; + if (label) { + _groupNames[i] = label; + } + } + + let _uncertainty = []; + _comments.map((c) => { + var unc = c.pass_count / c.count; + if (unc > 0.3) { + c.unc = unc; + _uncertainty.push(c); + } + }); + _uncertainty.sort((a, b) => { + return b.unc * b.unc * b.pass_count - a.unc * a.unc * a.pass_count; + }); + _uncertainty = _uncertainty.slice(0, 5); + + const _extremity = {}; + + for (const index in mathResult.pca["comment-extremity"]) { + const e = mathResult.pca["comment-extremity"][index]; + const tid = indexToTid[index]; + _extremity[tid] = e; + } + + var uniqueCommenters = {}; + var voteTotals = DataUtils.getVoteTotals(mathResult); + _comments = _comments.map((c) => { + c["group-aware-consensus"] = mathResult["group-aware-consensus"][c.tid]; + uniqueCommenters[c.pid] = 1; + c = Object.assign(c, voteTotals[c.tid]); + return c; + }); + var numUniqueCommenters = Object.keys(uniqueCommenters).length; + let totalVotes = 0; + for (const key in mathResult["user-vote-counts"]) { + totalVotes += mathResult["user-vote-counts"][key]; + } + const _computedStats = { + votesPerVoterAvg: totalVotes / _ptptCountTotal, + commentsPerCommenterAvg: _comments.length / numUniqueCommenters, + }; + + setLoading(false); + setMath(mathResult); + setConsensus(mathResult.consensus); + setExtremity(_extremity); + setUncertainty(_uncertainty.map((c) => { + return c.tid; + })); + setComments(_comments); + setGroupDemographics(_groupDemographics); + setParticipants(_participants); + setConversation(_conversation); + setPtptCount(_ptptCount); + SetPtptCountTotal(_ptptCountTotal); + setFilteredCorrelationMatrix(_filteredProbabilities); + setFilteredCorrelationTids(_filteredTids); + setBadTids(_badTids); + setGroupNames(_groupNames); + setRepfulAgreeTidsByGroup(_repfulAgreeTidsByGroup); + setRepfulDisageeTidsByGroup(_repfulDisageeTidsByGroup); + setFormatTid(() => _formatTid); + setReport(_report); + setComputedStats(_computedStats); + setNothingToShow(!_comments.length || !_groupDemographics.length); + }) + .catch((err) => { + console.error(err); + setError(true); + setErrorText(String(err)); + }); + } + + useEffect(() => { + const init = async () => { + await getData(); + setInterval(() => { + if (shouldPoll) { + getData(); + } + }, 20 * 1000); + }; + function handleResize() { + setDimensions({ + width: window.innerWidth, + height: window.innerHeight, + }); + } + + let resizeTimeout; + window.addEventListener("resize", () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(handleResize, 500); + }); + init(); + }, []); + + const onAutoRefreshEnabled = () => { + setShouldPoll(true); + } + + const onAutoRefreshDisabled = () => { + setShouldPoll(false); + } + + const handleColorblindModeClick = () => { + var colorBlind = !colorBlindMode; + if (colorBlind) { + setColorBlindMode(colorBlind); + setVoteColors(Object.assign(voteColors, { + agree: globals.brandColors.agreeColorblind, + })); + } else { + setColorBlindMode(colorBlind); + setVoteColors(Object.assign(voteColors, { + agree: globals.brandColors.agree, + })); + } + } + + if (hasError) { + return ( +
+
Error Loading
+
{errorText}
+
+ ); + } + if (nothingToShow) { + return ( +
+
Nothing to show yet
+
+ ); + } + if (loading) { + return ( +
+
Loading ...
+
+ ); + } + + return ( +
+ +
+ + + {/* This may eventually need to go back in below */} + {/* stats={conversationStats} */} + + + {!isNarrativeReport && ( + + )} + + {isNarrativeReport ? ( + <> + +

Current Model: {model}

+ {parsedNarrativeConsensus ? ( + + ) : "...Loading Consensus \n"} + {parsedNarrativeUncertainty ? ( + + ) : "...Loading Uncertainty \n"} + + ) : ( + <> + + + + + {/* {false ? : null} + {globals.enableMatrix && false ? : ""} */} + + {/* */} + + + )} +
+
+
+ ); +} + +export default App; diff --git a/client-report/src/components/app.test.jsx b/client-report/src/components/app.test.jsx new file mode 100644 index 000000000..fafc3cdbe --- /dev/null +++ b/client-report/src/components/app.test.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import App from './app'; + +// Mock data for testing +const mockData = { + conversation: { + conversation_id: 123, + }, + loading: false, + errorText: null, + nothingToShow: false, +}; + +jest.mock('./globals.js', () => ({ + brandColors: { + agree: 'green', + disagree: 'red', + pass: 'yellow', + }, + enableMatrix: true, +})); + +test('renders nothing to show message if there is no data', () => { + render(); + const nothingToShowText = screen.getByText(/Nothing to show yet/); + expect(nothingToShowText).toBeInTheDocument(); +}); \ No newline at end of file diff --git a/client-report/src/components/barChart.js b/client-report/src/components/barChart.jsx similarity index 97% rename from client-report/src/components/barChart.js rename to client-report/src/components/barChart.jsx index 50d24b5cc..faa4a913b 100644 --- a/client-report/src/components/barChart.js +++ b/client-report/src/components/barChart.jsx @@ -2,7 +2,7 @@ import React from "react"; -const BarChart = ({comment, groupVotes, ptptCount/*, conversation*/}) => { +const BarChart = ({comment, groupVotes, ptptCount}) => { const rectStartX = 70; const barHeight = 15; diff --git a/client-report/src/components/beeswarm/beeswarm.jsx b/client-report/src/components/beeswarm/beeswarm.jsx index 744bdcf54..22dfd89aa 100644 --- a/client-report/src/components/beeswarm/beeswarm.jsx +++ b/client-report/src/components/beeswarm/beeswarm.jsx @@ -1,7 +1,7 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React, { useState, useEffect } from "react"; -import CommentList from "../lists/commentList"; +import CommentList from "../lists/commentList.jsx"; import * as globals from "../globals"; import _ from "lodash"; // import Flex from "../framework/flex" diff --git a/client-report/src/components/beeswarm/beeswarm.test.jsx b/client-report/src/components/beeswarm/beeswarm.test.jsx index c0510ed4a..b449bf4a7 100644 --- a/client-report/src/components/beeswarm/beeswarm.test.jsx +++ b/client-report/src/components/beeswarm/beeswarm.test.jsx @@ -12,7 +12,7 @@ global.window.d3 = { }), domain: jest.fn() }), - extent: jest.fn(), + extent: jest.fn(() => [0, 1]), forceSimulation: jest.fn().mockReturnValue({ force: jest.fn().mockReturnThis(), stop: jest.fn().mockReturnThis(), diff --git a/client-report/src/components/comment.js b/client-report/src/components/comment.js deleted file mode 100644 index c1815e800..000000000 --- a/client-report/src/components/comment.js +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; -import PropTypes from "prop-types"; -import Flex from "./flex"; -// import ParticipantHeader from "./participant-header"; -import * as globals from "./globals"; -import BarChart from "./barChart"; - -class Comment extends React.Component { - static propTypes = { - dispatch: PropTypes.func, - params: PropTypes.object, - acceptButton: PropTypes.bool, - rejectButton: PropTypes.bool, - acceptClickHandler: PropTypes.func, - rejectClickHandler: PropTypes.func, - } - getDate() { - const date = new Date(+this.props.comment.created); - return `${date.getMonth()+1} / ${date.getUTCDate()} / ${date.getFullYear()}` - } - getVoteBreakdown(/*comment*/) { - if (typeof this.props.comment.agree_count !== "undefined") { - return ({this.props.comment.agree_count} agreed, {this.props.comment.disagree_count} disagreed, {this.props.comment.pass_count} passed); - } - return ""; - } - - render() { - // const showAsAnon = !this.props.comment.social || this.props.comment.anon || this.props.comment.is_seed; - - const styles = Object.assign({}, globals.paragraph, {fontStyle: "italic"}); - - return ( - - - - {this.props.formatTid(this.props.comment.tid)} - - {this.props.comment.is_meta ? "Metadata: " : ''} - { this.props.comment.txt } - - - - - - - ); - } - } - - export default Comment; - - //

{this.props.comment.demographics.gender}

- //

{this.props.comment.demographics.age}

- - // { - // showAsAnon ? - // "Anonymous" : - // - // } diff --git a/client-report/src/components/comment.jsx b/client-report/src/components/comment.jsx new file mode 100644 index 000000000..27fe32356 --- /dev/null +++ b/client-report/src/components/comment.jsx @@ -0,0 +1,66 @@ +// // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React from "react"; +import PropTypes from "prop-types"; +import Flex from "./flex"; +import * as globals from "./globals"; +import BarChart from "./barChart"; + +const Comment = ({ dispatch, params, acceptButton, rejectButton, acceptClickHandler, rejectClickHandler, comment, formatTid, conversation, ptptCount }) => { + const getDate = () => { + const date = new Date(+comment.created); + return `${date.getMonth() + 1} / ${date.getUTCDate()} / ${date.getFullYear()}`; + }; + + const getVoteBreakdown = () => { + if (typeof comment.agree_count !== "undefined") { + return ( + + ({comment.agree_count} agreed, {comment.disagree_count} disagreed, {comment.pass_count} passed) + + ); + } + return ""; + }; + + const styles = { ...globals.paragraph, fontStyle: "italic" }; + + return ( + + + + {formatTid(comment.tid)} - {comment.is_meta ? "Metadata: " : ""} + {comment.txt} + + + + + + + + ); +}; + +Comment.propTypes = { + dispatch: PropTypes.func, + params: PropTypes.object, + acceptButton: PropTypes.bool, + rejectButton: PropTypes.bool, + acceptClickHandler: PropTypes.func, + rejectClickHandler: PropTypes.func, + comment: PropTypes.object, + formatTid: PropTypes.func, + conversation: PropTypes.object, + ptptCount: PropTypes.number, +}; + +export default Comment; diff --git a/client-report/src/components/commentsGraph/commentsGraph.js b/client-report/src/components/commentsGraph/commentsGraph.jsx similarity index 65% rename from client-report/src/components/commentsGraph/commentsGraph.js rename to client-report/src/components/commentsGraph/commentsGraph.jsx index 02009929b..0713c1195 100644 --- a/client-report/src/components/commentsGraph/commentsGraph.js +++ b/client-report/src/components/commentsGraph/commentsGraph.jsx @@ -1,35 +1,22 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -import React from "react"; -// import _ from "lodash"; +import React, { setState } from "react"; import * as globals from "../globals"; import graphUtil from "../../util/graphUtil"; import Axes from "../graphAxes"; import Comments from "./comments"; -// const TextSegment = ({ t, i }) => ( -// -// {t} -// -// ); +const CommentsGraph = ({ comments, math, badTids, height, report, groupNames, formatTid, repfulAgreeTidsByGroup, renderHeading, voteColors }) => { + const [viewer, setViewer] = useState(null); + const [selectedComment, setSelectedComment] = useState(null); -class CommentsGraph extends React.Component { - constructor(props) { - super(props); - this.Viewer = null; - this.state = { - selectedComment: null, - }; - } - - handleCommentClick(selectedComment) { + const handleCommentClick = (sc) => { return () => { - this.setState({ selectedComment }); + setSelectedComment(sc); }; } - render() { - if (!this.props.math) { + if (math) { return null; } @@ -39,11 +26,9 @@ class CommentsGraph extends React.Component { commentsPoints, xCenter, yCenter, - // baseClustersScaled, commentScaleupFactorX, commentScaleupFactorY, - // hulls, - } = graphUtil(this.props.comments, this.props.math, this.props.badTids); + } = graphUtil(comments, math, badTids); return (
@@ -65,16 +50,16 @@ class CommentsGraph extends React.Component {

- {this.state.selectedComment - ? "#" + this.state.selectedComment.tid + ". " + this.state.selectedComment.txt + {selectedComment + ? "#" + selectedComment.tid + ". " + selectedComment.txt : "Click a statement, identified by its number, to explore regions of the graph."}

{/* Comment https://bl.ocks.org/mbostock/7555321 */} @@ -87,26 +72,15 @@ class CommentsGraph extends React.Component { textAnchor="middle" > - - {/* {} */} - {/* {this.props.math["group-clusters"].map((cluster, i) => { - return ( Renzi Supporters ) - }) : null} */} - {/* { - hulls.map((hull) => { - let gid = hull.group[0].gid; - if (_.isNumber(this.props.showOnlyGroup)) { - if (gid !== this.props.showOnlyGroup) { - return ""; - } - } - return - }) - } */} + {commentsPoints ? ( ); - } } export default CommentsGraph; diff --git a/client-report/src/components/controls/controls.js b/client-report/src/components/controls/controls.js deleted file mode 100644 index 630c6abd7..000000000 --- a/client-report/src/components/controls/controls.js +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - - -import Checkbox from '../framework/checkbox'; -import React from "react"; -import settings from "../../settings"; - -class Controls extends React.Component { - - constructor(props) { - super(props); - this.autoRefreshEnabledRef = React.createRef(); - this.colorBlindModeRef = React.createRef(); - } - - checkboxGroupChanged(newVal) { - if (newVal) { - this.props.onAutoRefreshEnabled(); - } else { - this.props.onAutoRefreshDisabled(); - } - } - - // UNSAFE_componentWillMount() { - // } - - render() { - return ( -
- - -
- ); - } - -} - // - -export default Controls; diff --git a/client-report/src/components/controls/controls.jsx b/client-report/src/components/controls/controls.jsx new file mode 100644 index 000000000..328e26bd5 --- /dev/null +++ b/client-report/src/components/controls/controls.jsx @@ -0,0 +1,46 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + + +import Checkbox from '../framework/checkbox.jsx'; +import React from "react"; +import settings from "../../settings"; + +const Controls = ({ onAutoRefreshEnabled, handleColorblindModeClick, colorBlindMode, onAutoRefreshDisabled, autoRefreshEnabled, voteColors}) => { + + const autoRefreshEnabledRef = React.createRef(); + const colorBlindModeRef = React.createRef(); + + const checkboxGroupChanged = (newVal) => { + if (newVal) { + onAutoRefreshEnabled(); + } else { + onAutoRefreshDisabled(); + } + } + + return ( +
+ + +
+ ); + +} + +export default Controls; diff --git a/client-report/src/components/correlationMatrix/matrix.js b/client-report/src/components/correlationMatrix/matrix.jsx similarity index 66% rename from client-report/src/components/correlationMatrix/matrix.js rename to client-report/src/components/correlationMatrix/matrix.jsx index a55e7bad2..5fbf5cbfb 100644 --- a/client-report/src/components/correlationMatrix/matrix.js +++ b/client-report/src/components/correlationMatrix/matrix.jsx @@ -1,40 +1,41 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -import React from "react"; +import React, { useState } from "react"; import _ from "lodash"; import * as globals from "../globals"; -var leftOffset = 34; -var topOffset = 60; +const leftOffset = 34; +const topOffset = 60; -var scale = d3.scaleLinear().domain([-1, 1]).range([0, 1]); +const scale = d3.scaleLinear().domain([-1, 1]).range([0, 1]); const square = 20; -class Matrix extends React.Component { - onMouseEnterCell(row, column, correlation) { - this.setState({ - mouseOverRow: row, - mouseOverColumn: column, - mouseOverCorrelation: correlation, - }); - } - onMouseExitCell(/*row, column*/) { - this.setState({ - mouseOverRow: null, - mouseOverColumn: null, - mouseOverCorrelation: null, - }); +const Matrix = ({ comments, tids, probabilities, title, error }) => { + const [mouseOverRow, setMouseOverRow] = useState(null); + const [mouseOverColumn, setMouseOverColumn] = useState(null); + const [mouseOverCorrelation, setMouseOverCorrelation] = useState(null); + + const onMouseEnterCell = (row, column, correlation) => { + setMouseOverRow(row); + setMouseOverColumn(column); + setMouseOverCorrelation(correlation); + }; + + const onMouseExitCell = (/*row, column*/) => { + setMouseOverRow(null); + setMouseOverColumn(null); + setMouseOverCorrelation(null); } - makeRect(comment, row, column) { + const makeRect = (comment, row, column) => { return ( { - return this.onMouseEnterCell(row, column, comment); + return onMouseEnterCell(row, column, comment); }} width={square} height={square} @@ -56,7 +57,7 @@ class Matrix extends React.Component { ); } - makeColumn(comments, row) { + const makeColumn = (comments, row) => { return comments.map((comment, column) => { let markup = null; if (column < row) { @@ -66,26 +67,26 @@ class Matrix extends React.Component { {/* this translate places the top text labels where they should go, rotated */} {/* this translate places the columns where they should go, and creates a gutter */} - {this.makeRect(comment, row, column)} + {makeRect(comment, row, column)} ); } else if (column === row) { - const comment = _.find(this.props.comments, (comment) => { - return comment.tid === this.props.tids[column]; + const comment = comments.find(comment => { + return comment.tid === tids[column]; }); markup = ( { - return this.onMouseExitCell(); + return onMouseExitCell(); }} onMouseLeave={() => { - return this.onMouseExitCell(); + return onMouseExitCell(); }} transform={"translate(" + (column * square + 10) + ", 46), rotate(315)"} fill={ - column === this.state.mouseOverColumn || column === this.state.mouseOverRow + column === mouseOverColumn || column === mouseOverRow ? "rgba(0,0,0,1)" : "rgba(0,0,0,0.5)" } @@ -104,31 +105,20 @@ class Matrix extends React.Component { }); } - makeRow(comments, row) { - // {/* this translate seperates the rows */} - // - // {this.props.formatTid(this.props.tids[row])} - // + const makeRow = (comments, row) => { return ( {/* this translate moves just the colored squares over to make a gutter, not the text */} - {this.makeColumn(comments, row)} + {makeColumn(comments, row)} ); } - renderMatrix() { - // console.log("mouseOverCorrelation", this.state.mouseOverCorrelation) - let side = this.props.probabilities.length * square + 200; + const renderMatrix = () => { + let side = probabilities.length * square + 200; return (
-

{this.props.title}

+

{title}

What is the chance that a participant who agreed (or disagreed) with a given comment also agreed (or disagreed) with another given comment? @@ -146,44 +136,45 @@ class Matrix extends React.Component { { - return this.onMouseExitCell(); + return onMouseExitCell(); }} onMouseLeave={() => { - return this.onMouseExitCell(); + return onMouseExitCell(); }} width={side} height={side} /> - {!this.state.mouseOverCorrelation ? ( + {!mouseOverCorrelation ? ( " " ) : ( {`${ - Math.round(this.state.mouseOverCorrelation * 1000) / 10 + Math.round(mouseOverCorrelation * 1000) / 10 }% chance of casting the same vote on these two statements`} )} - {this.props.probabilities.map((comments, row) => { - return {this.makeRow(comments, row)}; + {probabilities.map((comments, row) => { + return {makeRow(comments, row)}; })}

); - } - renderError(err) { + }; + + const renderError = (err) => { return (
error loading matrix
@@ -191,17 +182,18 @@ class Matrix extends React.Component {
); } - renderLoading() { + + const renderLoading = () => { return
loading matrix... (may take up to a minute)
; } - render() { - if (this.props.error) { - return this.renderError(); - } else if (this.props.probabilities) { - return this.renderMatrix(); - } else { - return this.renderLoading(); - } + + + if (error) { + return renderError(); + } else if (probabilities) { + return renderMatrix(); + } else { + return renderLoading(); } } diff --git a/client-report/src/components/framework/Footer.js b/client-report/src/components/framework/Footer.jsx similarity index 95% rename from client-report/src/components/framework/Footer.js rename to client-report/src/components/framework/Footer.jsx index 70e5d7e0c..ac6ce158e 100644 --- a/client-report/src/components/framework/Footer.js +++ b/client-report/src/components/framework/Footer.jsx @@ -1,7 +1,7 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React from "react"; -import LargeLogo from "./logoLargeShort"; +import LargeLogo from "./logoLargeShort.jsx"; const Footer = (/*{conversation}*/) => { return ( diff --git a/client-report/src/components/framework/checkbox.js b/client-report/src/components/framework/checkbox.js deleted file mode 100644 index f63fe4518..000000000 --- a/client-report/src/components/framework/checkbox.js +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; -import Color from "color"; -import settings from "../../settings"; - - -export default class Checkbox extends React.Component { - constructor(props) { - super(props); - this.state = { - checked: this.props.checked, - active: false - }; - } - - static defaultProps = { - checked: true, - clickHandler: (x) => { return x }, - color: settings.darkGray, - - } - - activeHandler () { - this.setState({ active: !this.state.active }); - } - - clickHandler () { - var newState = !this.state.checked; - this.setState({ checked: newState }); - if (this.props.clickHandler) { this.props.clickHandler(newState); } - } - - getWrapperStyles () { - return { - display: "block", - marginBottom: 10, - position: "relative" - }; - } - - getLabelWrapperStyles () { - return { - color: this.props.labelWrapperColor, - cursor: "pointer", - display: "inline-block", - fontFamily: settings.fontFamilySansSerif, - fontSize: 14, - fontWeight: 400, - lineHeight: "20px", - paddingLeft: 22, - // "-webkit-user-select": "none" - }; - } - - getCheckboxStyles () { - const activeColor = Color(this.props.color).lighten(0.2).hex(); - - return { - base: { - backgroundColor: settings.gray, - borderRadius: 2, - display: "inline-block", - height: 12, - left: -17, - position: "relative", - top: 1, - transition: "background-color ease .3s", - width: 12 - }, - checked: { - backgroundColor: this.props.color - }, - active: { - backgroundColor: activeColor - } - }; - } - - getLabelStyles () { - return { - display: "inline", - left: -12, - marginRight: 4, - position: "relative" - }; - } - - getHelpTextStyles () { - return { - color: "#ccc", - cursor: "pointer", - display: "inline", - fontFamily: settings.fontFamilySansSerif, - fontSize: 12, - fontWeight: 200, - lineHeight: "20px", - marginLeft: 5 - }; - } - - render () { - const {base, checked, active} = this.getCheckboxStyles(); - const styles = Object.assign({}, base, this.state.checked ? checked : {}, this.state.active ? active : {}); - console.log(styles); - - return ( -
- - - - - {this.props.label} - {this.props.helpText ? ( - - ({this.props.helpText}) - - ) : null } - - -
- ); - } -} diff --git a/client-report/src/components/framework/checkbox.jsx b/client-report/src/components/framework/checkbox.jsx new file mode 100644 index 000000000..c7feea945 --- /dev/null +++ b/client-report/src/components/framework/checkbox.jsx @@ -0,0 +1,118 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React, { useState } from "react"; +import Color from "color"; +import settings from "../../settings"; + + +const Checkbox = ({ isChecked, color = settings.darkGray, clickHandler = (x) => { return x }, labelWrapperColor, label, helpText }) => { + + const [checked, setChecked] = useState(isChecked); + const [active, setActive] = useState(false); + + const activeHandler = () => { + setActive(a => !a); + }; + + const clickHandlerInternal = () => { + const newState = !checked; + setChecked(newState); + if (clickHandler) { clickHandler(newState); } + } + + const getWrapperStyles = () => { + return { + display: "block", + marginBottom: 10, + position: "relative" + }; + } + + const getLabelWrapperStyles = () => { + return { + color: labelWrapperColor, + cursor: "pointer", + display: "inline-block", + fontFamily: settings.fontFamilySansSerif, + fontSize: 14, + fontWeight: 400, + lineHeight: "20px", + paddingLeft: 22, + }; + } + + const getCheckboxStyles = () => { + const activeColor = Color(color).lighten(0.2).hex(); + + return { + base: { + backgroundColor: settings.gray, + borderRadius: 2, + display: "inline-block", + height: 12, + left: -17, + position: "relative", + top: 1, + transition: "background-color ease .3s", + width: 12 + }, + checkedStyle: { + backgroundColor: color + }, + activeStyle: { + backgroundColor: activeColor + } + }; + } + + const getLabelStyles = () => { + return { + display: "inline", + left: -12, + marginRight: 4, + position: "relative" + }; + } + + const getHelpTextStyles = () => { + return { + color: "#ccc", + cursor: "pointer", + display: "inline", + fontFamily: settings.fontFamilySansSerif, + fontSize: 12, + fontWeight: 200, + lineHeight: "20px", + marginLeft: 5 + }; + } + + + const {base, checkedStyle, activeStyle} = getCheckboxStyles(); + const styles = Object.assign({}, base, checked ? checkedStyle : {}, active ? activeStyle : {}); + + return ( +
+ + + + + {label} + {helpText ? ( + + ({helpText}) + + ) : null } + + +
+ ); +} + +export default Checkbox; diff --git a/client-report/src/components/framework/checkbox.test.jsx b/client-report/src/components/framework/checkbox.test.jsx new file mode 100644 index 000000000..a84ea9248 --- /dev/null +++ b/client-report/src/components/framework/checkbox.test.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Checkbox from './checkbox'; +import settings from '../../settings'; // Assuming settings is imported correctly + +import '@testing-library/jest-dom'; + +describe('Checkbox Component', () => { + it('renders checkbox with correct initial state and styles', () => { + const label = 'Test Label'; + const isChecked = true; + const color = 'blue'; // Override default color + + render(); + + const checkbox = screen.getByTestId('checkbox'); + const labelText = screen.getByText(label); + + expect(checkbox).toHaveStyle({ backgroundColor: color }); + }); + + it('toggles checkbox state and calls clickHandler on click', () => { + const label = 'Test Label'; + const isChecked = false; + const mockClickHandler = jest.fn(); + + render(); + + const checkbox = screen.getByRole('checkbox'); + + fireEvent.click(checkbox); + + + expect(mockClickHandler).toHaveBeenCalledTimes(1); + expect(mockClickHandler).toHaveBeenCalledWith(true); // New state after click + }); + + it('shows help text if provided', () => { + const label = 'Test Label'; + const isChecked = true; + const helpText = 'This is some help text'; + + render(); + + const helpTextElement = screen.getByText(/This is some help text/); + + expect(helpTextElement).toBeInTheDocument(); + }); + + it('hides help text if not provided', () => { + const label = 'Test Label'; + const isChecked = true; + + render(); + + const helpTextElement = screen.queryByText(/help text/i); // Use queryByText for potential absence + + expect(helpTextElement).not.toBeInTheDocument(); + }); + +}); \ No newline at end of file diff --git a/client-report/src/components/framework/flex.js b/client-report/src/components/framework/flex.js deleted file mode 100644 index 84c2ffdec..000000000 --- a/client-report/src/components/framework/flex.js +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import PropTypes from "prop-types"; -import React from "react"; - -/** - - flex-direction: row | row-reverse | column | column-reverse; - flex-wrap: nowrap | wrap | wrap-reverse; - justify-content: flex-start | flex-end | center | space-between | space-around; - align-items: flex-start | flex-end | center | baseline | stretch; - align-content: flex-start | flex-end | center | space-between | space-around | stretch; - flex is growShrinkBasis - -**/ - -class Flex extends React.Component { - static propTypes = { - direction: PropTypes.oneOf([ - "row", "rowReverse", "column", "columnReverse" - ]), - wrap: PropTypes.oneOf([ - "nowrap", "wrap", "wrap-reverse" - ]), - justifyContent: PropTypes.oneOf([ - "flex-start", "flex-end", "center", "space-between", "space-around" - ]), - alignItems: PropTypes.oneOf([ - "flex-start", "flex-end", "center", "baseline", "stretch" - ]), - alignContent: PropTypes.oneOf([ - "flex-start", "flex-end", "center", "space-between", "space-around", "stretch" - ]), - grow: PropTypes.number, - shrink: PropTypes.number, - basis: PropTypes.string, - order: PropTypes.number, - alignSelf: PropTypes.oneOf([ - "auto", "flex-start", "flex-end", "center", "baseline", "stretch" - ]), - styleOverrides: PropTypes.object, - children: PropTypes.node, - clickHandler: PropTypes.func, - } - static defaultProps = { - direction: "row", - wrap: "nowrap", - justifyContent: "center", - alignItems: "center", - alignContent: "stretch", - grow: 0, - shrink: 1, - basis: "auto", - alignSelf: "auto", - order: 0, - styleOverrides: {} - } - getStyles() { - return { - base: { - display: "flex", - flexDirection: this.props.direction, - flexWrap: this.props.wrap, - justifyContent: this.props.justifyContent, - alignItems: this.props.alignItems, - alignContent: this.props.alignContent, - order: this.props.order, - flexGrow: this.props.grow, - flexShrink: this.props.shrink, - flexBasis: this.props.basis, - alignSelf: this.props.alignSelf, - }, - styleOverrides: this.props.styleOverrides - } - } - - render() { - const {base, styleOverrides} = this.getStyles(); - const styles = Object.assign({}, base, styleOverrides); - console.log(styles) - - return ( -
- {/* style={[ - styles.base, - styles.styleOverrides - ]} */} - {this.props.children} -
- ); - } -} - -export default Flex; diff --git a/client-report/src/components/framework/flex.jsx b/client-report/src/components/framework/flex.jsx new file mode 100644 index 000000000..f6b130b73 --- /dev/null +++ b/client-report/src/components/framework/flex.jsx @@ -0,0 +1,119 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import PropTypes from "prop-types"; +import React from "react"; + +/** + + flex-direction: row | row-reverse | column | column-reverse; + flex-wrap: nowrap | wrap | wrap-reverse; + justify-content: flex-start | flex-end | center | space-between | space-around; + align-items: flex-start | flex-end | center | baseline | stretch; + align-content: flex-start | flex-end | center | space-between | space-around | stretch; + flex is growShrinkBasis + +**/ + +const Flex = ({ + direction = "row", + wrap = "nowrap", + justifyContent = "center", + alignItems = "center", + alignContent = "stretch", + grow = 0, + shrink = 1, + basis = "auto", + alignSelf = "auto", + order = 0, + styleOverrides = {}, + children, + clickHandler, +}) => { + const getStyles = () => ({ + base: { + display: "flex", + flexDirection: direction, + flexWrap: wrap, + justifyContent, + alignItems, + alignContent, + order, + flexGrow: grow, + flexShrink: shrink, + flexBasis: basis, + alignSelf, + }, + styleOverrides, + }); + + const styles = { ...getStyles().base, ...getStyles().styleOverrides }; + + return ( +
+ {children} +
+ ); +}; + +Flex.propTypes = { + direction: PropTypes.oneOf([ + "row", + "rowReverse", + "column", + "columnReverse", + ]), + wrap: PropTypes.oneOf(["nowrap", "wrap", "wrap-reverse"]), + justifyContent: PropTypes.oneOf([ + "flex-start", + "flex-end", + "center", + "space-between", + "space-around", + ]), + alignItems: PropTypes.oneOf([ + "flex-start", + "flex-end", + "center", + "baseline", + "stretch", + ]), + alignContent: PropTypes.oneOf([ + "flex-start", + "flex-end", + "center", + "space-between", + "space-around", + "stretch", + ]), + grow: PropTypes.number, + shrink: PropTypes.number, + basis: PropTypes.string, + order: PropTypes.number, + alignSelf: PropTypes.oneOf([ + "auto", + "flex-start", + "flex-end", + "center", + "baseline", + "stretch", + ]), + styleOverrides: PropTypes.object, + children: PropTypes.node, + clickHandler: PropTypes.func, +}; + +Flex.defaultProps = { + direction: "row", + wrap: "nowrap", + justifyContent: "center", + alignItems: "center", + alignContent: "stretch", + grow: 0, + shrink: 1, + basis: "auto", + alignSelf: "auto", + order: 0, + styleOverrides: {}, +}; + +export default Flex; diff --git a/client-report/src/components/framework/heading.js b/client-report/src/components/framework/heading.jsx similarity index 97% rename from client-report/src/components/framework/heading.js rename to client-report/src/components/framework/heading.jsx index 30a5716b0..ff89af6e7 100644 --- a/client-report/src/components/framework/heading.js +++ b/client-report/src/components/framework/heading.jsx @@ -1,7 +1,7 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React from "react"; -import SmallLogo from "./logoSmallLong"; +import SmallLogo from "./logoSmallLong.jsx"; import Url from "../../util/url"; const urlPrefix = Url.urlPrefix; diff --git a/client-report/src/components/framework/legend.js b/client-report/src/components/framework/legend.jsx similarity index 100% rename from client-report/src/components/framework/legend.js rename to client-report/src/components/framework/legend.jsx diff --git a/client-report/src/components/framework/logoLargeShort.js b/client-report/src/components/framework/logoLargeShort.js deleted file mode 100644 index d04da88dd..000000000 --- a/client-report/src/components/framework/logoLargeShort.js +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; - -class PolisLogo extends React.Component { - styles() { - return { - link: { - textDecoration: "none", - cursor: "pointer", - padding: "8px 0px 4px 10px" - } - } - } - render() { - return ( - - - - - - - - - p. - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - } -} - -export default PolisLogo; diff --git a/client-report/src/components/framework/logoLargeShort.jsx b/client-report/src/components/framework/logoLargeShort.jsx new file mode 100644 index 000000000..a245b0b2c --- /dev/null +++ b/client-report/src/components/framework/logoLargeShort.jsx @@ -0,0 +1,58 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React from "react"; + +const PolisLogo = ({ invert = false }) => { + const styles = { + link: { + textDecoration: "none", + cursor: "pointer", + padding: "8px 0px 4px 10px", + }, + }; + + const svgContent = ( + + + + + + + + p. + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + + return ( + + {svgContent} + + ); +}; + +export default PolisLogo; diff --git a/client-report/src/components/framework/logoSmallLong.js b/client-report/src/components/framework/logoSmallLong.js deleted file mode 100644 index ea94b90a0..000000000 --- a/client-report/src/components/framework/logoSmallLong.js +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; - -class PolisLogo extends React.Component { - render() { - return ( - - - - - - - - - - p. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - } -} - -export default PolisLogo; diff --git a/client-report/src/components/framework/logoSmallLong.jsx b/client-report/src/components/framework/logoSmallLong.jsx new file mode 100644 index 000000000..5ed4352a3 --- /dev/null +++ b/client-report/src/components/framework/logoSmallLong.jsx @@ -0,0 +1,216 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React from "react"; + +const PolisLogo = () => { + return ( + + + + + + + + + + p. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default PolisLogo; diff --git a/client-report/src/components/graphAxes.js b/client-report/src/components/graphAxes.js deleted file mode 100644 index 784e97879..000000000 --- a/client-report/src/components/graphAxes.js +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; -import * as globals from "./globals"; - -const GraphAxes = ({yCenter, xCenter/*, report*/}) => { - return ( - - - - - ); -}; - -export default GraphAxes; -// -// {/* Bottom axis */} -// -// {report.label_x_neg ? -// {globals.axisLabels.leftArrow} -// {" "} -// {report.label_x_neg} -// : ""} -// {report.label_x_pos ? -// {report.label_x_pos} -// {" "} -// {globals.axisLabels.rightArrow} -// : ""} -// -// -// {/* Left axis */} -// -// {report.label_y_neg ? -// {globals.axisLabels.leftArrow} -// {" "} -// {report.label_y_neg} -// : ""} -// {report.label_y_pos ? -// {report.label_y_pos} -// {" "} -// {globals.axisLabels.rightArrow} -// : ""} -// diff --git a/client-report/src/components/graphAxes.jsx b/client-report/src/components/graphAxes.jsx new file mode 100644 index 000000000..02bf824dc --- /dev/null +++ b/client-report/src/components/graphAxes.jsx @@ -0,0 +1,31 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React from "react"; +import * as globals from "./globals"; + +const GraphAxes = ({yCenter, xCenter/*, report*/}) => { + return ( + + + + + ); +}; + +export default GraphAxes; diff --git a/client-report/src/components/lists/allCommentsModeratedIn.js b/client-report/src/components/lists/allCommentsModeratedIn.js deleted file mode 100644 index 5c14fe71f..000000000 --- a/client-report/src/components/lists/allCommentsModeratedIn.js +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; -import _ from "lodash"; -import CommentList from "./commentList"; -import * as globals from "../globals"; - - -function sortByTid(comments) { - return _.map(comments, (comment) => comment.tid).sort((a, b) => a - b); -} - -function sortByVoteCount(comments) { - return _.map(_.reverse(_.sortBy(comments, "count")), (c) => {return c.tid;}); -} -function sortByGroupAwareConsensus(comments) { - return _.map(_.reverse(_.sortBy(comments, (c) => {return c["group-aware-consensus"];})), (c) => {return c.tid;}); -} -function sortByPctAgreed(comments) { - return _.map(_.reverse(_.sortBy(comments, (c) => {return c["pctAgreed"];})), (c) => {return c.tid;}); -} -function sortByPctDisagreed(comments) { - return _.map(_.reverse(_.sortBy(comments, (c) => {return c["pctDisagreed"];})), (c) => {return c.tid;}); -} -function sortByPctPassed(comments) { - return _.map(_.reverse(_.sortBy(comments, (c) => {return c["pctPassed"];})), (c) => {return c.tid;}); -} - -class allCommentsModeratedIn extends React.Component { - - constructor(props) { - super(props); - this.state = { - sortStyle: globals.allCommentsSortDefault, - }; - } - - onSortChanged(event) { - this.setState({ - sortStyle: event.target.value, - }); - } - - render() { - - if (!this.props.conversation) { - return
Loading allCommentsModeratedIn...
- } - - let sortFunction = null; - if (this.state.sortStyle === "tid") { - sortFunction = sortByTid; - } else if (this.state.sortStyle === "numvotes") { - sortFunction = sortByVoteCount; - } else if (this.state.sortStyle === "consensus") { - sortFunction = sortByGroupAwareConsensus; - } else if (this.state.sortStyle === "pctAgreed") { - sortFunction = sortByPctAgreed; - } else if (this.state.sortStyle === "pctDisagreed") { - sortFunction = sortByPctDisagreed; - } else if (this.state.sortStyle === "pctPassed") { - sortFunction = sortByPctPassed; - } else { - console.error('missing sort function', this.state.sortStyle); - } - - return ( -
-

All statements

-

- Group votes across all statements, excluding those statements which were moderated out. -

- - -
- -
-
- ); - } -} - -export default allCommentsModeratedIn; diff --git a/client-report/src/components/lists/allCommentsModeratedIn.jsx b/client-report/src/components/lists/allCommentsModeratedIn.jsx new file mode 100644 index 000000000..f4f80b0d4 --- /dev/null +++ b/client-report/src/components/lists/allCommentsModeratedIn.jsx @@ -0,0 +1,61 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React, { useState } from "react"; +import CommentList from "./commentList.jsx"; +import * as globals from "../globals"; + + +const sortFunctions = { + tid: (comments) => comments.sort((a, b) => a.tid - b.tid).map(c => c.tid), + numvotes: (comments) => comments.sort((a, b) => b.count - a.count).map(c => c.tid), // Descending order for numvotes + consensus: (comments) => comments.sort((a, b) => b["group-aware-consensus"] - a["group-aware-consensus"]).map(c => c.tid), + pctAgreed: (comments) => comments.sort((a, b) => b["pctAgreed"] - a["pctAgreed"]).map(c => c.tid), + pctDisagreed: (comments) => comments.sort((a, b) => b["pctDisagreed"] - a["pctDisagreed"]).map(c => c.tid), + pctPassed: (comments) => comments.sort((a, b) => b["pctPassed"] - a["pctPassed"]).map(c => c.tid), +}; + +const allCommentsModeratedIn = ({ conversation, ptptCount, math, formatTid, comments, voteColors }) => { + + const [sortStyle, setSortStyle] = useState(globals.allCommentsSortDefault) + + const onSortChanged = (event) => { + setSortStyle(event.target.value) + }; + + + if (!conversation) { + return
Loading allCommentsModeratedIn...
+ } + + const sortFunction = sortFunctions[sortStyle] || sortFunctions["tid"]; + + return ( +
+

All statements

+

+ Group votes across all statements, excluding those statements which were moderated out. +

+ + +
+ +
+
+ ); +} + +export default allCommentsModeratedIn; diff --git a/client-report/src/components/lists/allCommentsModeratedIn.test.jsx b/client-report/src/components/lists/allCommentsModeratedIn.test.jsx new file mode 100644 index 000000000..ad3c02af3 --- /dev/null +++ b/client-report/src/components/lists/allCommentsModeratedIn.test.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import AllCommentsModeratedIn from './allCommentsModeratedIn.jsx'; +import CommentList from './commentList.jsx'; + +jest.mock('./commentList', () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe('allCommentsModeratedIn component', () => { + const mockComments = [ + { tid: 1, count: 10, "group-aware-consensus": 0.7 }, + { tid: 2, count: 5, "group-aware-consensus": 0.8 }, + ]; + + test('renders loading message when conversation is not provided', () => { + render(); + expect(screen.getByText('Loading allCommentsModeratedIn...')).toBeInTheDocument(); + }); + + test('renders comments with tid sorting by default', () => { + const mockConversation = {}; + render(); + expect(CommentList).toHaveBeenCalledWith({"comments": [{"count": 10, "group-aware-consensus": 0.7, "tid": 1}, {"count": 5, "group-aware-consensus": 0.8, "tid": 2}], "conversation": {}, "formatTid": undefined, "math": undefined, "ptptCount": undefined, "tidsToRender": [1, 2], "voteColors": undefined}, {} + ); + }); + + test('sorts comments by numvotes when selected', () => { + const mockConversation = {}; + render( + + ); + expect(CommentList).toHaveBeenCalledWith({"comments": [{"count": 10, "group-aware-consensus": 0.7, "tid": 1}, {"count": 5, "group-aware-consensus": 0.8, "tid": 2}], "conversation": {}, "formatTid": undefined, "math": undefined, "ptptCount": undefined, "tidsToRender": [1, 2], "voteColors": undefined}, {}); + }); + + test('sorts comments by other criteria based on sortStyle', () => { + const mockConversation = {}; + const sortStyles = ['consensus', 'pctAgreed', 'pctDisagreed', 'pctPassed']; + sortStyles.forEach((sortStyle) => { + render( + + ); + expect(CommentList).toHaveBeenCalled(); // Ensure CommentList is called + }); + }); +}); \ No newline at end of file diff --git a/client-report/src/components/lists/commentList.js b/client-report/src/components/lists/commentList.jsx similarity index 76% rename from client-report/src/components/lists/commentList.js rename to client-report/src/components/lists/commentList.jsx index 722eeaaf1..0844a1037 100644 --- a/client-report/src/components/lists/commentList.js +++ b/client-report/src/components/lists/commentList.jsx @@ -1,7 +1,6 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React from "react"; -import _ from "lodash"; import * as globals from "../globals"; const BarChartCompact = ({ comment, voteCounts, nMembers, voteColors }) => { @@ -82,17 +81,17 @@ const CommentRow = ({ comment, groups, voteColors }) => { console.error("WHY IS THERE NO COMMENT 3452354235", comment); return null; } - // const percentAgreed = Math.floor(groupVotesForThisGroup.votes[comment.tid].A / groupVotesForThisGroup.votes[comment.tid].S * 100); let BarCharts = []; let totalMembers = 0; // groups - _.forEach(groups, (g, i) => { - let nMembers = g["n-members"]; + Object.entries(groups).forEach(([key, g]) => { + const i = parseInt(key, 10); // Parse the key to an integer + const nMembers = g["n-members"]; totalMembers += nMembers; - let gVotes = g.votes[comment.tid]; - + const gVotes = g.votes[comment.tid]; + BarCharts.push( { ); }); - // totals column - // let globalCounts = { - // A: comment.agreed, - // D: comment.disagreed, - // S: comment.saw, - // }; BarCharts.unshift( { ); }; -class CommentList extends React.Component { - getGroupLabels() { +const CommentList = ({ comments, math, ptptCount, tidsToRender, voteColors }) => { + + const getGroupLabels = () => { function makeLabel(key, label, numMembers) { return ( { + Object.entries(math["group-votes"]).forEach(([key, g]) => { + const i = parseInt(key, 10); labels.push(makeLabel(i, globals.groupLabels[i], g["n-members"])); }); return labels; } - render() { - const comments = _.keyBy(this.props.comments, "tid"); + const cs = comments.reduce((acc, comment) => { + acc[comment.tid] = comment; + return acc; + }, {}); - return ( -
-
+
+ - - Statement - + Statement + - {this.getGroupLabels()} -
- {this.props.tidsToRender.map((tid, i) => { - return ( - - ); - })} + {getGroupLabels()}
- ); - } + {tidsToRender.map((tid, i) => { + return ( + + ); + })} +
+ ); } export default CommentList; diff --git a/client-report/src/components/lists/consensusNarrative.js b/client-report/src/components/lists/consensusNarrative.jsx similarity index 77% rename from client-report/src/components/lists/consensusNarrative.js rename to client-report/src/components/lists/consensusNarrative.jsx index 8a24c6850..ccc4e34ca 100644 --- a/client-report/src/components/lists/consensusNarrative.js +++ b/client-report/src/components/lists/consensusNarrative.jsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; -import * as globals from "../globals"; -import Narrative from "../narrative"; -import CommentList from "./commentList"; +import * as globals from "../globals.js"; +import Narrative from "../narrative/index.jsx"; +import CommentList from "./commentList.jsx"; const ConsensusNarrative = ({ math, comments, @@ -12,10 +12,11 @@ const ConsensusNarrative = ({ narrative, model }) => { - if (!narrative?.group_informed_consensus) { + + if (!narrative) { return
Loading Consensus...
; } - const txt = model === "claude" ? narrative.group_informed_consensus.responseClaude.content[0].text : narrative.group_informed_consensus.responseGemini; + const txt = model === "claude" ? narrative.responseClaude.content[0].text : narrative.responseGemini; const narrativeJSON = model === "claude" ? JSON.parse(`{${txt}`) : JSON.parse(txt); @@ -39,7 +40,7 @@ const ConsensusNarrative = ({

This narrative summary may contain hallucinations. Check each clause.

- +
{ + return ({ sectionData, model }) => ( +
+ Narrative Component - Model: {model} - Data: {JSON.stringify(sectionData)} +
+ ); +}); + +jest.mock('./commentList.jsx', () => { + return ({ conversation, ptptCount, math, formatTid, tidsToRender, comments, voteColors }) => ( +
+ CommentList Component - TIDs: {JSON.stringify(tidsToRender)} +
+ ); +}); + +describe('ConsensusNarrative Component', () => { + const mockNarrativeData = { + responseClaude: { + content: [{ text: '"paragraphs":[{"sentences":[{"clauses":[{"citations":["tid1","tid2"]}]}]}]}' }], + }, + responseGemini: '{"paragraphs":[{"sentences":[{"clauses":[{"citations":["tid2","tid3","tid1"]}]}]}]}', + }; + + + const mockProps = { + math: {}, + comments: [], + conversation: {}, + ptptCount: 5, + formatTid: jest.fn((tid) => `Formatted ${tid}`), + voteColors: {}, + narrative: mockNarrativeData, + model: "claude", + }; + + it('renders loading message when narrative data is missing', () => { + render(); + expect(screen.getByText('Loading Consensus...')).toBeInTheDocument(); + }); + + it('renders the component with Claude model', async () => { + render(); + + expect(screen.getByText('Group Aware Consensus Narrative')).toBeInTheDocument(); + expect(screen.getByText('This narrative summary may contain hallucinations. Check each clause.')).toBeInTheDocument(); + + expect(screen.getByTestId('mock-narrative')).toHaveTextContent('Model: claude'); + expect(screen.getByTestId('mock-comment-list')).toHaveTextContent('TIDs: ["tid1","tid2"]'); + }); + + it('renders the component with Gemini model', async () => { + render(); + + expect(screen.getByText('Group Aware Consensus Narrative')).toBeInTheDocument(); + expect(screen.getByText('This narrative summary may contain hallucinations. Check each clause.')).toBeInTheDocument(); + + expect(screen.getByTestId('mock-narrative')).toHaveTextContent('Model: gemini'); + expect(screen.getByTestId('mock-comment-list')).toHaveTextContent('TIDs: ["tid2","tid3","tid1"]'); + }); +}); \ No newline at end of file diff --git a/client-report/src/components/lists/majorityStrict.js b/client-report/src/components/lists/majorityStrict.jsx similarity index 95% rename from client-report/src/components/lists/majorityStrict.js rename to client-report/src/components/lists/majorityStrict.jsx index a379beed1..cf8d7454f 100644 --- a/client-report/src/components/lists/majorityStrict.js +++ b/client-report/src/components/lists/majorityStrict.jsx @@ -2,8 +2,8 @@ import React from "react"; import * as globals from "../globals"; -import CommentList from "./commentList"; -import Legend from "../framework/legend"; +import CommentList from "./commentList.jsx"; +import Legend from "../framework/legend.jsx"; const MajorityStrict = ({ conversation, diff --git a/client-report/src/components/lists/metadata.js b/client-report/src/components/lists/metadata.jsx similarity index 95% rename from client-report/src/components/lists/metadata.js rename to client-report/src/components/lists/metadata.jsx index ecc016f20..7af09ff06 100644 --- a/client-report/src/components/lists/metadata.js +++ b/client-report/src/components/lists/metadata.jsx @@ -1,8 +1,8 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React from "react"; -import CommentList from "./commentList"; -import * as globals from "../globals"; +import CommentList from "./commentList.jsx"; +import * as globals from "../globals.js"; const Metadata = ({ conversation, comments, ptptCount, formatTid, math, voteColors }) => { if (!conversation) { diff --git a/client-report/src/components/lists/participantGroup.js b/client-report/src/components/lists/participantGroup.jsx similarity index 82% rename from client-report/src/components/lists/participantGroup.js rename to client-report/src/components/lists/participantGroup.jsx index 19f580a07..6527c75ca 100644 --- a/client-report/src/components/lists/participantGroup.js +++ b/client-report/src/components/lists/participantGroup.jsx @@ -1,10 +1,8 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React from "react"; -import * as globals from "../globals"; -// import Flex from "../framework/flex" -// import style from "../../util/style"; -import CommentList from "./commentList"; +import * as globals from "../globals.js"; +import CommentList from "./commentList.jsx"; const ParticipantGroup = ({ gid, @@ -12,12 +10,9 @@ const ParticipantGroup = ({ conversation, comments, groupVotesForThisGroup, - // groupVotesForOtherGroups, - // demographicsForGroup, ptptCount, groupName, formatTid, - // groupNames, math, voteColors, }) => { @@ -41,13 +36,9 @@ const ParticipantGroup = ({ ptptCount={ptptCount} math={math} formatTid={formatTid} - tidsToRender={_.map(groupComments, 'tid') /* uncertainTids would be funnier */} + tidsToRender={groupComments.map(c => c.tid)} comments={comments} voteColors={voteColors}/> - - - -
); }; diff --git a/client-report/src/components/lists/participantGroups.js b/client-report/src/components/lists/participantGroups.js deleted file mode 100644 index d2bed6971..000000000 --- a/client-report/src/components/lists/participantGroups.js +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; -// import _ from "lodash"; -import Group from "./participantGroup"; -// import style from "../../util/style"; -import * as globals from "../globals"; -import Metadata from "./metadata"; - -class ParticipantGroups extends React.Component { - constructor(props) { - super(props); - this.state = { - - }; - } - getStyles() { - return { - base: { - - } - }; - } - render() { - const styles = this.getStyles(); - if (!this.props.conversation) { - return
Loading Groups
; - } - return ( -
-
-

Opinion Groups

-

- Across {this.props.ptptCount} total participants, {this.props.math["group-votes"].length} opinion groups emerged. There are two factors that define an opinion group. First, each opinion group is made up of a number of participants who tended to vote similarly on multiple statements. Second, each group of participants who voted similarly will have also voted distinctly differently from other groups. -

- - { - this.props.math && this.props.comments ? _.map(this.props.math["repness"], (groupComments, gid) => { - gid = Number(gid); - - let otherGroupVotes = { - votes: [], - "n-members": 0, - }; - - let MAX_CLUSTERS = 50; - let temp = this.props.math["group-votes"]; - for (let ogid = 0; ogid < MAX_CLUSTERS; ogid++) { - if (ogid === gid || !temp[ogid]) { - continue; - } - otherGroupVotes["n-members"] += temp[ogid]["n-members"]; - let commentVotes = temp[ogid].votes; - _.each(commentVotes, (voteObj, tid) => { - tid = Number(tid); - if (voteObj) { - if (!otherGroupVotes.votes[tid]) { - otherGroupVotes.votes[tid] = {A: 0, D: 0, S: 0}; - } - otherGroupVotes.votes[tid].A += voteObj.A; - otherGroupVotes.votes[tid].D += voteObj.D; - otherGroupVotes.votes[tid].S += voteObj.S; - - } - }); - } - return ( - - ); - }) : "Loading Groups" - } -
-
- ); - } -} - -export default ParticipantGroups; diff --git a/client-report/src/components/lists/participantGroups.jsx b/client-report/src/components/lists/participantGroups.jsx new file mode 100644 index 000000000..9d0567f21 --- /dev/null +++ b/client-report/src/components/lists/participantGroups.jsx @@ -0,0 +1,128 @@ +// // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React, { useState, useEffect } from "react"; +import Group from "./participantGroup.jsx"; +import * as globals from "../globals.js"; +import Metadata from "./metadata.jsx"; + +const ParticipantGroups = ({ + conversation, + ptptCount, + math, + comments, + style, + voteColors, + formatTid, + demographics, + groupNames, + badTids, + repfulAgreeTidsByGroup, + repfulDisageeTidsByGroup, + report, +}) => { + const [isLoading, setIsLoading] = useState(true); + const [groups, setGroups] = useState([]); + + useEffect(() => { + if (!conversation || !math || !comments) return; + + const processGroups = () => { + const processedGroups = Object.keys(math["repness"]).map((gid) => { // Use Object.keys and map + gid = Number(gid); + + let otherGroupVotes = { + votes: [], + "n-members": 0, + }; + + const MAX_CLUSTERS = 50; + const temp = math["group-votes"]; + + for (let ogid = 0; ogid < MAX_CLUSTERS; ogid++) { + if (ogid === gid || !temp[ogid]) { + continue; + } + otherGroupVotes["n-members"] += temp[ogid]["n-members"]; + const commentVotes = temp[ogid].votes; + + Object.keys(commentVotes || {}).forEach((tid) => { // Use Object.keys and forEach + tid = Number(tid); + const voteObj = commentVotes[tid]; + if (voteObj) { + if (!otherGroupVotes.votes[tid]) { + otherGroupVotes.votes[tid] = { A: 0, D: 0, S: 0 }; + } + otherGroupVotes.votes[tid].A += voteObj.A; + otherGroupVotes.votes[tid].D += voteObj.D; + otherGroupVotes.votes[tid].S += voteObj.S; + } + }); + } + + return ( + + ); + }); + + setGroups(processedGroups); + setIsLoading(false); + }; + + processGroups(); + }, [conversation, math, comments]); + + const styles = { + base: {}, + ...style, + }; + + return ( +
+
+

Opinion Groups

+

+ Across {ptptCount} total participants, {math && Object.keys(math["group-votes"])?.length}{" "} + opinion groups emerged. There are two factors that define an opinion + group. First, each opinion group is made up of a number of participants + who tended to vote similarly on multiple statements. Second, each group + of participants who voted similarly will have also voted distinctly + differently from other groups. +

+ + {isLoading ? ( +
Loading Groups
+ ) : ( + groups + )} +
+
+ ); +}; + +export default ParticipantGroups; \ No newline at end of file diff --git a/client-report/src/components/lists/participantGroups.test.jsx b/client-report/src/components/lists/participantGroups.test.jsx new file mode 100644 index 000000000..f375f0d3c --- /dev/null +++ b/client-report/src/components/lists/participantGroups.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ParticipantGroups from './participantGroups'; +import * as globals from '../globals'; // Mock globals if necessary +import Metadata from './metadata.jsx'; // Mock Metadata +import Group from './participantGroup.jsx'; // Mock Group +import '@testing-library/jest-dom'; + +jest.mock('../globals', () => ({ + primaryHeading: { fontSize: '20px' }, + paragraph: { fontSize: '16px' }, +})); + +jest.mock('./metadata.jsx', () => () =>
); +jest.mock('./participantGroup.jsx', () => ({ groupName }) => ( +
{groupName}
+)); + +describe('ParticipantGroups Component', () => { + const mockProps = { + conversation: {}, + ptptCount: 100, + math: { + "group-votes": { + 0: { "n-members": 20, votes: { 1: { A: 10, D: 5, S: 5 }, 2: { A: 15, D: 0, S: 5 } } }, + 1: { "n-members": 30, votes: { 1: { A: 5, D: 15, S: 10 }, 3: { A: 20, D: 5, S: 5 } } }, + }, + repness: { + 0: { someData: 'group0data' }, + 1: { someData: 'group1data' }, + }, + }, + comments: {}, + voteColors: {}, + formatTid: jest.fn((tid) => `TID${tid}`), + demographics: { + 0: {someDemo: "demo0"}, + 1: {someDemo: "demo1"} + }, + groupNames: { + 0: "Group A", + 1: "Group B" + }, + badTids: [], + repfulAgreeTidsByGroup: {}, + repfulDisageeTidsByGroup: {}, + report: {}, + style: { backgroundColor: 'lightgray' } + }; + + it('renders loading message when data is missing', () => { + render(); + expect(screen.getByText('Loading Groups')).toBeInTheDocument(); + }); + + it('renders component with groups when data is present', () => { + render(); + + expect(screen.getByText('Opinion Groups')).toBeInTheDocument(); + expect(screen.getByText(/Across 100 total participants, 2 opinion groups emerged/)).toBeInTheDocument(); + expect(screen.getByTestId('mock-metadata')).toBeInTheDocument(); + expect(screen.getByTestId('mock-group-Group A')).toBeInTheDocument(); + expect(screen.getByTestId('mock-group-Group B')).toBeInTheDocument(); + }); + + it("renders correct number of groups", () => { + render(); + expect(screen.getAllByTestId(/mock-group-/).length).toBe(2); + }) +}); \ No newline at end of file diff --git a/client-report/src/components/lists/uncertainty.js b/client-report/src/components/lists/uncertainty.jsx similarity index 92% rename from client-report/src/components/lists/uncertainty.js rename to client-report/src/components/lists/uncertainty.jsx index fe42b9eff..3d8b40c3d 100644 --- a/client-report/src/components/lists/uncertainty.js +++ b/client-report/src/components/lists/uncertainty.jsx @@ -1,10 +1,10 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React from "react"; -import CommentList from "./commentList"; -import * as globals from "../globals"; +import CommentList from "./commentList.jsx"; +import * as globals from "../globals.js"; // import style from "../../util/style"; -import Narrative from "../narrative"; +import Narrative from "../narrative/index.jsx"; const Uncertainty = ({ conversation, diff --git a/client-report/src/components/lists/uncertaintyNarrative.js b/client-report/src/components/lists/uncertaintyNarrative.jsx similarity index 81% rename from client-report/src/components/lists/uncertaintyNarrative.js rename to client-report/src/components/lists/uncertaintyNarrative.jsx index 4200afa59..77dfec218 100644 --- a/client-report/src/components/lists/uncertaintyNarrative.js +++ b/client-report/src/components/lists/uncertaintyNarrative.jsx @@ -1,27 +1,25 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React from "react"; -import CommentList from "./commentList"; -import * as globals from "../globals"; -// import style from "../../util/style"; -import Narrative from "../narrative"; +import CommentList from "./commentList.jsx"; +import * as globals from "../globals.js"; +import Narrative from "../narrative/index.jsx"; const UncertaintyNarrative = ({ conversation, comments, ptptCount, - uncertainty, formatTid, math, voteColors, narrative, model }) => { - if (!conversation || !narrative || !narrative?.uncertainty?.responseClaude) { + if (!conversation || !narrative || !narrative?.responseClaude || !narrative?.responseGemini) { return
Loading Uncertainty...
; } - const txt = model === "claude" ? narrative?.uncertainty.responseClaude.content[0].text : narrative?.uncertainty.responseGemini; + const txt = model === "claude" ? narrative?.responseClaude.content[0].text : narrative?.responseGemini; const narrativeJSON = model === "claude" ? JSON.parse(`{${txt}`) : JSON.parse(txt); @@ -46,7 +44,7 @@ const UncertaintyNarrative = ({

This narrative summary may contain hallucinations. Check each clause.

- +
({ model }) =>
); // Mock Narrative +jest.mock('./commentList.jsx', () => () =>
); +import '@testing-library/jest-dom'; + +describe('UncertaintyNarrative Component', () => { + const mockProps = { + conversation: {}, + comments: {}, + ptptCount: 100, + formatTid: jest.fn((tid) => `TID${tid}`), + math: {}, + voteColors: {}, + narrative: { + responseClaude: { content: [{ text: '"paragraphs":[{"sentences":[{"clauses":[{"citations":["T1","T2"]}]}]}]}' }] }, + responseGemini: '{"paragraphs":[{"sentences":[{"clauses":[{"citations":["T3"]}]}]}]}' + }, + model: 'claude' + }; + + it('renders loading message when data is missing', () => { + render(); + expect(screen.getByText('Loading Uncertainty...')).toBeInTheDocument(); + }); + + it('renders component with narrative and comment list when data is present (Claude model)', () => { + render(); + + expect(screen.getByText('Uncertainty Narrative')).toBeInTheDocument(); + expect(screen.getByText('This narrative summary may contain hallucinations. Check each clause.')).toBeInTheDocument(); + expect(screen.getByTestId('mock-narrative-claude')).toBeInTheDocument(); + expect(screen.getByTestId('mock-comment-list')).toBeInTheDocument(); + }); + + it('renders component with narrative and comment list when data is present (Gemini model)', () => { + render(); + + expect(screen.getByText('Uncertainty Narrative')).toBeInTheDocument(); + expect(screen.getByText('This narrative summary may contain hallucinations. Check each clause.')).toBeInTheDocument(); + expect(screen.getByTestId('mock-narrative-gemini')).toBeInTheDocument(); + expect(screen.getByTestId('mock-comment-list')).toBeInTheDocument(); + }); + +}); \ No newline at end of file diff --git a/client-report/src/components/narrative/index.js b/client-report/src/components/narrative/index.jsx similarity index 96% rename from client-report/src/components/narrative/index.js rename to client-report/src/components/narrative/index.jsx index fd7c15a8a..86b8218b1 100644 --- a/client-report/src/components/narrative/index.js +++ b/client-report/src/components/narrative/index.jsx @@ -3,8 +3,6 @@ import React from "react"; const Narrative = ({ sectionData, model }) => { if (!sectionData) return null; - console.log("narrativeData", sectionData); - const txt = model === "claude" ? sectionData.responseClaude.content[0].text : sectionData.responseGemini; const respData = model === "claude" ? JSON.parse(`{${txt}`) : JSON.parse(txt); diff --git a/client-report/src/components/overview.js b/client-report/src/components/overview.jsx similarity index 83% rename from client-report/src/components/overview.js rename to client-report/src/components/overview.jsx index 1a90e38be..208480a7a 100644 --- a/client-report/src/components/overview.js +++ b/client-report/src/components/overview.jsx @@ -1,23 +1,18 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import React from "react"; -import _ from "lodash"; import * as globals from "./globals"; const computeVoteTotal = (users) => { let voteTotal = 0; - _.each(users, (count) => { - voteTotal += count; - }); + for (const count in users) { + voteTotal += users[count]; + } return voteTotal; }; -// const computeUniqueCommenters = (comments) => { - -// } - const Number = ({ number, label }) => (

{number.toLocaleString()}

@@ -81,11 +76,3 @@ const Overview = ({ conversation, ptptCount, ptptCountTotal, math, computedStats }; export default Overview; - -//

{conversation && conversation.participant_count ? "A total of "+ptptCount+" people participated. " : null}

- -// It was presented {conversation ? conversation.medium : "loading"} to an audience of {conversation ? conversation.audiences : "loading"}. -// The conversation was run for {conversation ? conversation.duration : "loading"}. -// {demographics ? demographics.foo : "loading"} were women - -// {conversation && conversation.description ? "The specific question was '"+conversation.description+"'. ": null} diff --git a/client-report/src/components/participantsGraph/groupLabels.js b/client-report/src/components/participantsGraph/groupLabels.jsx similarity index 95% rename from client-report/src/components/participantsGraph/groupLabels.js rename to client-report/src/components/participantsGraph/groupLabels.jsx index 9adff438f..8253d0ae4 100644 --- a/client-report/src/components/participantsGraph/groupLabels.js +++ b/client-report/src/components/participantsGraph/groupLabels.jsx @@ -3,16 +3,6 @@ import * as globals from "../globals"; import React from "react"; -// const getBackgroundRectWidth = (ptptCount) => { -// let width = 46; /* smallest number */ -// if (ptptCount >= 100 && ptptCount < 1000) { -// width = 52; -// } else if (ptptCount > 1000) { -// width = 59; -// } -// return width; -// } - const Users = ({selectedGroup}) => { return ( diff --git a/client-report/src/components/participantsGraph/hull.js b/client-report/src/components/participantsGraph/hull.jsx similarity index 100% rename from client-report/src/components/participantsGraph/hull.js rename to client-report/src/components/participantsGraph/hull.jsx diff --git a/client-report/src/components/participantsGraph/participantsGraph.js b/client-report/src/components/participantsGraph/participantsGraph.js deleted file mode 100644 index a7ba3b80a..000000000 --- a/client-report/src/components/participantsGraph/participantsGraph.js +++ /dev/null @@ -1,451 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import React from "react"; -import _ from "lodash"; -import * as globals from "../globals"; -import graphUtil from "../../util/graphUtil"; -import Axes from "../graphAxes"; -import * as d3contour from "d3-contour"; -import * as d3chromatic from "d3-scale-chromatic"; -// import GroupLabels from "./groupLabels"; -import Comments from "../commentsGraph/comments.jsx"; -import Hull from "./hull"; -import CommentList from "../lists/commentList"; - -const pointsPerSquarePixelMax = 0.0017; /* choose dynamically ? */ -const contourBandwidth = 20; -const colorScaleDownFactor = 0.5; /* The colors are too dark. This helps. */ - -const color = d3 - .scaleSequential(d3chromatic.interpolateYlGnBu) - .domain([0, pointsPerSquarePixelMax]); -const geoPath = d3.geoPath(); - -const Contour = ({ contour }) => ( - -); - -const Participants = ({ points, math }) => { - if (!points) { - return null; - } - - return ( - - {points.map((pt, i) => { - return ( - - - - {" "} - {globals.groupSymbols[pt.gid]} - - - ); - // return ( - // {globals.groupSymbols[pt.gid]} - // ) - })} - - ); -}; - -class ParticipantsGraph extends React.Component { - constructor(props) { - super(props); - this.Viewer = null; - this.state = { - selectedComment: null, - showContour: false, - showGroupLabels: true, - showParticipants: false, - showGroupOutline: false, - showComments: true, - showAxes: true, - showRadialAxes: true, - }; - } - - handleCommentClick(selectedComment) { - return () => { - this.setState({ selectedComment }); - }; - } - - getInnerRadialAxisColor() { - let color = globals.brandColors.lightgrey; - if (this.props.consensusDivisionColorScale && this.props.colorBlindMode) { - color = globals.brandColors.blue; - } else if (this.props.consensusDivisionColorScale && !this.props.colorBlindMode) { - color = this.props.voteColors.agree; - } - return color; - } - - render() { - if (!this.props.math) { - return null; - } - - const { - xx, - yy, - commentsPoints, - xCenter, - yCenter, - baseClustersScaled, - commentScaleupFactorX, - commentScaleupFactorY, - hulls, - } = graphUtil(this.props.comments, this.props.math, this.props.badTids); - - const contours = d3contour - .contourDensity() - .x(function (d) { - return d.x; - }) - .y(function (d) { - return d.y; - }) - .size([globals.side, globals.side]) - // .bandwidth(10)(baseClustersScaled) - .bandwidth(contourBandwidth)(baseClustersScaled); - - return ( -
-
-

Graph

-

- Which statements were voted on similarly? How do participants relate to each other? -

-

- In this graph, statements are positioned more closely to statements which were voted on - similarly. Participants, in turn, are positioned more closely to statements on which - they agreed, and further from statements on which they disagreed. This means - participants who voted similarly are closer together. -

-
-
- { - // - } - - - {/* */} - - - - -
- -
- {this.state.selectedComment ? ( - - ) : ( -

Click a statement, identified by its number, to explore regions of the graph.

- )} -
- - {this.state.showParticipants ? ( -

- {hulls.map((h, i) => { - return ( - - {`${globals.groupSymbols[i]}`} - - {" "} - {`${globals.groupLabels[i]}`}{" "} - - - ); - })} -

- ) : null} - - - - - - - - - - {this.state.showRadialAxes ? ( - - - - - - ) : null} - {this.state.showContour - ? contours.map((contour, i) => ) - : null} - {this.state.showAxes ? ( - - ) : null} - {this.state.showGroupOutline - ? hulls.map((hull) => { - let gid = hull.group[0].gid; - if (_.isNumber(this.props.showOnlyGroup)) { - if (gid !== this.props.showOnlyGroup) { - return ""; - } - } - return ; - }) - : null} - {this.state.showParticipants ? ( - - ) : null} - {this.state.showComments ? ( - - ) : null} - {this.state.showGroupLabels - ? this.props.math["group-clusters"].map((g, i) => { - // console.log('g',g ) - return ( - - {globals.groupLabels[g.id]} - - ); - }) - : null} - {this.state.consensusDivisionColorScale ? ( - - - - - - - - - ) : null} - -
- ); - } -} - -export default ParticipantsGraph; diff --git a/client-report/src/components/participantsGraph/participantsGraph.jsx b/client-report/src/components/participantsGraph/participantsGraph.jsx new file mode 100644 index 000000000..3e7d39da8 --- /dev/null +++ b/client-report/src/components/participantsGraph/participantsGraph.jsx @@ -0,0 +1,389 @@ +// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +import React, { useState } from "react"; +import * as globals from "../globals.js"; +import graphUtil from "../../util/graphUtil.js"; +import Axes from "../graphAxes.jsx"; +import * as d3contour from "d3-contour"; +import * as d3chromatic from "d3-scale-chromatic"; +import Comments from "../commentsGraph/comments.jsx"; +import Hull from "./hull.jsx"; +import CommentList from "../lists/commentList.jsx"; + +const pointsPerSquarePixelMax = 0.0017; /* choose dynamically ? */ +const contourBandwidth = 20; +const colorScaleDownFactor = 0.5; /* The colors are too dark. This helps. */ + + +const Contour = ({ contour }) => { + const color = window.d3 + .scaleSequential(d3chromatic.interpolateYlGnBu) + .domain([0, pointsPerSquarePixelMax]); + const geoPath = window.d3.geoPath(); + return +}; + +const Participants = ({ points, math }) => { + if (!points) { + return null; + } + + return ( + + {points.map((pt, i) => { + return ( + + + + {" "} + {globals.groupSymbols[pt.gid]} + + + ); + })} + + ); +}; + +const ParticipantsGraph = (props) => { + const [selectedComment, setSelectedComment] = useState(null); + const [showContour, setShowContour] = useState(false); + const [showGroupLabels, setShowGroupLabels] = useState(true); + const [showParticipants, setShowParticipants] = useState(false); + const [showGroupOutline, setShowGroupOutline] = useState(false); + const [showComments, setShowComments] = useState(true); + const [showAxes, setShowAxes] = useState(true); + const [showRadialAxes, setShowRadialAxes] = useState(true); + + const handleCommentClick = (sc) => { + return () => { + setSelectedComment(sc); + }; + } + + const getInnerRadialAxisColor = () => { + let color = globals.brandColors.lightgrey; + if (props.consensusDivisionColorScale && props.colorBlindMode) { + color = globals.brandColors.blue; + } else if (props.consensusDivisionColorScale && !props.colorBlindMode) { + color = props.voteColors.agree; + } + return color; + } + + if (!props.math) { + return null; + } + + const { + xx, + yy, + commentsPoints, + xCenter, + yCenter, + baseClustersScaled, + commentScaleupFactorX, + commentScaleupFactorY, + hulls, + } = graphUtil(props.comments, props.math, props.badTids); + + const contours = d3contour + .contourDensity() + .x(function (d) { + return d.x; + }) + .y(function (d) { + return d.y; + }) + .size([globals.side, globals.side]) + // .bandwidth(10)(baseClustersScaled) + .bandwidth(contourBandwidth)(baseClustersScaled); + + return ( +
+
+

Graph

+

+ Which statements were voted on similarly? How do participants relate to each other? +

+

+ In this graph, statements are positioned more closely to statements which were voted on + similarly. Participants, in turn, are positioned more closely to statements on which + they agreed, and further from statements on which they disagreed. This means + participants who voted similarly are closer together. +

+
+
+ + + + + + +
+ +
+ {selectedComment ? ( + + ) : ( +

Click a statement, identified by its number, to explore regions of the graph.

+ )} +
+ + {showParticipants ? ( +

+ {hulls.map((h, i) => { + return ( + + {`${globals.groupSymbols[i]}`} + + {" "} + {`${globals.groupLabels[i]}`}{" "} + + + ); + })} +

+ ) : null} + + + + + + + + + + {showRadialAxes ? ( + + + + + + ) : null} + {showContour + ? contours.map((contour, i) => ) + : null} + {showAxes ? ( + + ) : null} + {showGroupOutline + ? hulls.map((hull) => { + let gid = hull.group[0].gid; + if (typeof props.showOnlyGroup === 'number' && isFinite(props.showOnlyGroup)) { + if (gid !== props.showOnlyGroup) { + return ""; + } + } + return ; + }) + : null} + {showParticipants ? ( + + ) : null} + {showComments ? ( + + ) : null} + {showGroupLabels + ? props.math["group-clusters"].map((g, i) => { + // console.log('g',g ) + return ( + + {globals.groupLabels[g.id]} + + ); + }) + : null} + {props.consensusDivisionColorScale ? ( + + + + + + + + + ) : null} + +
+ ); +} + +export default ParticipantsGraph; diff --git a/client-report/src/components/participantsGraph/participantsGraph.test.jsx b/client-report/src/components/participantsGraph/participantsGraph.test.jsx new file mode 100644 index 000000000..4f9c41021 --- /dev/null +++ b/client-report/src/components/participantsGraph/participantsGraph.test.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ParticipantsGraph from './participantsGraph'; +import '@testing-library/jest-dom'; +import * as d3contour from "d3-contour"; +import * as d3chromatic from "d3-scale-chromatic"; +import * as d3geo from "d3-geo"; + +Object.defineProperty(window, 'd3', { + writable: true, +}); + +global.window.d3 = { + scaleLinear: jest.fn(() => { + const mockScale = { // Create a mock scale object + domain: jest.fn(() => mockScale), // Return the mockScale itself + rangeRound: jest.fn(() => mockScale), // Return the mockScale itself + range: jest.fn(() => jest.fn()), // Add range for completeness + // Add other methods as needed (e.g., tickFormat) + }; + return mockScale; // Return the mock scale object + }), + geoPath: jest.fn(() => jest.fn()), + extent: jest.fn(() => [0, 1]), // Mock extent to return a default range + forceSimulation: jest.fn().mockReturnValue({ + force: jest.fn().mockReturnThis(), + stop: jest.fn().mockReturnThis(), + tick: jest.fn() + }), + forceX: jest.fn().mockReturnValue({ // Add a mock return for forceX + strength: jest.fn().mockReturnThis() // Add a mock for strength + }), + forceY: jest.fn(), + forceCollide: jest.fn(), + voronoi: jest.fn().mockReturnValue({ + extent: jest.fn().mockReturnThis(), + x: jest.fn().mockReturnThis(), + y: jest.fn().mockReturnThis(), + polygons: jest.fn().mockReturnValue([ + { + join: jest.fn(), + data: {} + }, + ]) + }) +} + +jest.mock('d3-contour', () => ({ + contourDensity: jest.fn(() => ({ + x: jest.fn(() => ({ + y: jest.fn(() => ({ + size: jest.fn(() => ({ + bandwidth: jest.fn(() => jest.fn()) + })) + })) + })) + })) +})); + +jest.mock('d3-scale-chromatic', () => ({ + interpolateYlGnBu: jest.fn() +})); + +jest.mock('d3-geo', () => ({ + geoPath: jest.fn() +})); + +import * as d3 from 'd3'; + +// Mock data (replace with your actual data structure) +const mockProps = { + math: { + "base-clusters": { count: { id1: 10, id2: 5 }, id: 0, x: [0], y: [0] }, + "group-clusters": [ + { id: 0, center: [0.5, 0.5], members: [0,1] }, + { id: 1, center: [0.2, 0.8], members: [0,1] }, + ], + tids: [0, 1], + pca: { + 'comment-projection': [ + [ + -2.7290480249930327, + -0.7919407161501923, + ], + [ + 1.4406572645339384, + 1.1672237459165768, + ] + ] + } + }, + comments: [ + { tid: 0, txt: 'Comment 1' }, + { tid: 1, txt: 'Comment 2' }, + ], + voteColors: { agree: 'green', disagree: 'red' }, + badTids: [], + formatTid: (n) => n +}; + +// Test case for rendering with basic data +test('renders participants graph with basic data', () => { + render(); + + // Check for presence of elements + expect(screen.getByRole('button', { name: 'Axes' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Statements' })).toBeInTheDocument(); + + // Check for group labels (if data includes group labels) + if (mockProps.math["group-clusters"][0].hasOwnProperty('label')) { + expect(screen.getByText(mockProps.math["group-clusters"][0].label)).toBeInTheDocument(); + } +}); + diff --git a/client-report/src/index.js b/client-report/src/index.js index 6f7b0fb57..79c6568a1 100644 --- a/client-report/src/index.js +++ b/client-report/src/index.js @@ -4,9 +4,7 @@ import React from "react"; import { createRoot } from 'react-dom/client'; import './index.css'; -import App from "./components/app"; - -// const store = configureStore(); +import App from "./components/app.jsx"; class Root extends React.Component { render() { diff --git a/client-report/src/store/index.js b/client-report/src/store/index.js deleted file mode 100644 index 90d873609..000000000 --- a/client-report/src/store/index.js +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -import { createStore, applyMiddleware, compose } from "redux"; -import thunk from "redux-thunk"; - -import rootReducer from "../reducers"; - -const middleware = [thunk]; - -let finalCreateStore; - -if (process.env.NODE_ENV === "production") { - finalCreateStore = applyMiddleware(...middleware)(createStore); -} else { - finalCreateStore = compose( - applyMiddleware(...middleware), - window.devToolsExtension ? window.devToolsExtension() : (f) => f - )(createStore); -} - -const configureStore = function (initialState) { - return finalCreateStore(rootReducer, initialState); -}; - -export default configureStore; diff --git a/client-report/src/util/dataUtils.js b/client-report/src/util/dataUtils.js index 8ab2bc3c9..23cb9eea0 100644 --- a/client-report/src/util/dataUtils.js +++ b/client-report/src/util/dataUtils.js @@ -1,29 +1,42 @@ // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -import _ from "lodash"; +const getVoteTotals = (math_main) => { + const x = {}; + const gv = math_main["group-votes"]; + if (gv) { + for (const gid in gv) { + if (gv.hasOwnProperty(gid)) { // Important: check own properties + const data = gv[gid]; + if (data && data.votes) { + for (const tid in data.votes) { + if (data.votes.hasOwnProperty(tid)) { // Important: check own properties + const counts = data.votes[tid]; + x[tid] = x[tid] || { agreed: 0, disagreed: 0, saw: 0 }; + x[tid].agreed += counts?.A || 0; + x[tid].disagreed += counts?.D || 0; + x[tid].saw += counts?.S || 0; + } + } + } + } + } + } -function getVoteTotals(math_main) { - var x = {}; - var gv = math_main["group-votes"]; - _.each(gv, function(data/*, gid*/) { - _.each(data.votes, function(counts, tid) { - var z = x[tid] = x[tid] || {agreed:0, disagreed:0, saw:0}; - z.agreed += counts.A; - z.disagreed += counts.D; - z.saw += counts.S; - }); - }); - _.each(x, function(z) { - z.pctAgreed = z.agreed / z.saw; - z.pctDisagreed = z.disagreed / z.saw; - z.pctVoted = (z.saw - z.disagreed - z.agreed) / z.saw; - }); + for (const tid in x) { + if (x.hasOwnProperty(tid)) { // Important: check own properties + const z = x[tid]; + z.pctAgreed = z.saw > 0 ? z.agreed / z.saw : 0; + z.pctDisagreed = z.saw > 0 ? z.disagreed / z.saw : 0; + z.pctVoted = z.saw > 0 ? (z.saw - z.disagreed - z.agreed) / z.saw : 0; + } + } return x; -} +}; const dataUtils = { - getVoteTotals: getVoteTotals, + getVoteTotals, }; + export default dataUtils; diff --git a/client-report/src/util/dataUtils.test.js b/client-report/src/util/dataUtils.test.js new file mode 100644 index 000000000..5f1b56072 --- /dev/null +++ b/client-report/src/util/dataUtils.test.js @@ -0,0 +1,101 @@ +import dataUtils from './dataUtils'; + +describe('dataUtils', () => { + describe('getVoteTotals', () => { + it('should return an empty object if math_main is empty or has no group-votes', () => { + expect(dataUtils.getVoteTotals({})).toEqual({}); + expect(dataUtils.getVoteTotals({ otherData: 'something' })).toEqual({}); + expect(dataUtils.getVoteTotals({ "group-votes": null })).toEqual({}); + expect(dataUtils.getVoteTotals({ "group-votes": undefined })).toEqual({}); + }); + + it('should calculate vote totals correctly for a single group', () => { + const math_main = { + "group-votes": { + 0: { + votes: { + 1: { A: 10, D: 5, S: 15 }, + 2: { A: 5, D: 10, S: 15 }, + }, + }, + }, + }; + const expected = { + 1: { agreed: 10, disagreed: 5, saw: 15, pctAgreed: 10/15, pctDisagreed: 5/15, pctVoted: 0/15 }, + 2: { agreed: 5, disagreed: 10, saw: 15, pctAgreed: 5/15, pctDisagreed: 10/15, pctVoted: 0/15 }, + }; + expect(dataUtils.getVoteTotals(math_main)).toEqual(expected); + }); + + it('should calculate vote totals correctly for multiple groups', () => { + const math_main = { + "group-votes": { + 0: { + votes: { + 1: { A: 10, D: 5, S: 15 }, + 2: { A: 5, D: 10, S: 15 }, + }, + }, + 1: { + votes: { + 1: { A: 2, D: 3, S: 5 }, + 3: { A: 7, D: 1, S: 8 }, + }, + }, + }, + }; + const expected = { + 1: { agreed: 12, disagreed: 8, saw: 20, pctAgreed: 12/20, pctDisagreed: 8/20, pctVoted: 0/20 }, + 2: { agreed: 5, disagreed: 10, saw: 15, pctAgreed: 5/15, pctDisagreed: 10/15, pctVoted: 0/15 }, + 3: { agreed: 7, disagreed: 1, saw: 8, pctAgreed: 7/8, pctDisagreed: 1/8, pctVoted: 0/8 }, + }; + expect(dataUtils.getVoteTotals(math_main)).toEqual(expected); + }); + + it('should handle missing A, D, or S counts', () => { + const math_main = { + "group-votes": { + 0: { + votes: { + 1: { A: 10, D: 5 }, // Missing S + 2: { S: 15 }, // Missing A and D + }, + }, + }, + }; + const expected = { + 1: { agreed: 10, disagreed: 5, saw: 0, pctAgreed: 0, pctDisagreed: 0, pctVoted: 0 }, + 2: { agreed: 0, disagreed: 0, saw: 15, pctAgreed: 0, pctDisagreed: 0, pctVoted: 1 }, + }; + expect(dataUtils.getVoteTotals(math_main)).toEqual(expected); + }); + + it('should handle empty votes object', () => { + const math_main = { + "group-votes": { + 0: { + votes: {}, + }, + }, + }; + const expected = {}; + expect(dataUtils.getVoteTotals(math_main)).toEqual(expected); + }); + + it('should handle saw = 0 to prevent division by zero', () => { + const math_main = { + "group-votes": { + 0: { + votes: { + 1: { A: 10, D: 5, S: 0 }, + }, + }, + }, + }; + const expected = { + 1: { agreed: 10, disagreed: 5, saw: 0, pctAgreed: 0, pctDisagreed: 0, pctVoted: 0 }, + }; + expect(dataUtils.getVoteTotals(math_main)).toEqual(expected); + }); + }); +}); \ No newline at end of file diff --git a/client-report/src/util/graphUtil.js b/client-report/src/util/graphUtil.js index 5a3c33d89..990e90ab5 100644 --- a/client-report/src/util/graphUtil.js +++ b/client-report/src/util/graphUtil.js @@ -8,7 +8,10 @@ const graphUtil = (comments, math, badTids) => { const allXs = []; const allYs = []; - const commentsByTid = _.keyBy(comments, "tid"); + const commentsByTid = comments.reduce((accumulator, comment) => { + accumulator[comment.tid] = comment; + return accumulator; + }, {}); const indexToTid = math.tids; const tidToIndex = []; for (let i = 0; i < indexToTid.length; i++) { @@ -78,24 +81,56 @@ const graphUtil = (comments, math, badTids) => { // let minClusterY = _.min(allYs); // let maxClusterY = _.max(allYs); - var greatestAbsPtptX = Math.abs(_.maxBy(baseClusters, (pt) => { return Math.abs(pt.x); }).x); - var greatestAbsPtptY = Math.abs(_.maxBy(baseClusters, (pt) => { return Math.abs(pt.y); }).y); + let greatestAbsPtptX = baseClusters.reduce((max, pt) => { + return Math.max(max, Math.abs(pt.x)); + }, 0); // Initialize max to 0 + + let greatestAbsPtptY = baseClusters.reduce((max, pt) => { + return Math.max(max, Math.abs(pt.y)); + }, 0); // Initialize max to 0 + // var greatestAbsCommentX = Math.abs(_.maxBy(commentsPoints, (pt) => { return Math.abs(pt.x); }).x); // var greatestAbsCommentY = Math.abs(_.maxBy(commentsPoints, (pt) => { return Math.abs(pt.y); }).y); - const xx = d3.scaleLinear().domain([-greatestAbsPtptX, greatestAbsPtptX]).range([border, globals.side - border]); - const yy = d3.scaleLinear().domain([-greatestAbsPtptY, greatestAbsPtptY]).range([border, globals.side - border]); + const xx = window.d3.scaleLinear().domain([-greatestAbsPtptX, greatestAbsPtptX]).range([border, globals.side - border]); + const yy = window.d3.scaleLinear().domain([-greatestAbsPtptY, greatestAbsPtptY]).range([border, globals.side - border]); const xCenter = globals.side / 2; const yCenter = globals.side / 2; - var maxCommentX = _.maxBy(commentsPoints, (pt) => { return pt.x; }).x; - var minCommentX = _.minBy(commentsPoints, (pt) => { return pt.x; }).x; - var maxCommentY = _.maxBy(commentsPoints, (pt) => { return pt.y; }).y; - var minCommentY = _.minBy(commentsPoints, (pt) => { return pt.y; }).y; + let maxCommentX = commentsPoints.length > 0 ? commentsPoints[0].x : undefined; // Handle empty array + for (let i = 1; i < commentsPoints.length; i++) { + if (commentsPoints[i].x > maxCommentX) { + maxCommentX = commentsPoints[i].x; + } + } + + // Find minCommentX + let minCommentX = commentsPoints.length > 0 ? commentsPoints[0].x : undefined; // Handle empty array + for (let i = 1; i < commentsPoints.length; i++) { + if (commentsPoints[i].x < minCommentX) { + minCommentX = commentsPoints[i].x; + } + } + + // Find maxCommentY + let maxCommentY = commentsPoints.length > 0 ? commentsPoints[0].y : undefined; // Handle empty array + for (let i = 1; i < commentsPoints.length; i++) { + if (commentsPoints[i].y > maxCommentY) { + maxCommentY = commentsPoints[i].y; + } + } + + // Find minCommentY + let minCommentY = commentsPoints.length > 0 ? commentsPoints[0].y : undefined; // Handle empty array + for (let i = 1; i < commentsPoints.length; i++) { + if (commentsPoints[i].y < minCommentY) { + minCommentY = commentsPoints[i].y; + } + } // xGreatestMapped = xCenter + xScale * maxCommentX // globals.side - border = xCenter + xScale * maxCommentX @@ -139,19 +174,22 @@ const graphUtil = (comments, math, badTids) => { const hulls = []; - _.each(baseClustersScaledAndGrouped, (group) => { - const pairs = group.map((g) => { /* create an array of arrays */ - return [g.x, g.y] - }) - const hull = createHull( - pairs, - 400 - ) + for (const group of Object.entries(baseClustersScaledAndGrouped)) { + // Destructure the group entry (key and value) + const [groupName, groupPoints] = group; + + // Create an array of coordinate pairs + const pairs = groupPoints.map((g) => [g.x, g.y]); + + // Calculate the convex hull + const hull = createHull(pairs, 400); + + // Push the result to hulls hulls.push({ - group, + group: groupName, hull, - }) - }) + }); + } return { xx, diff --git a/client-report/src/util/graphUtil.test.js b/client-report/src/util/graphUtil.test.js new file mode 100644 index 000000000..2549f33ad --- /dev/null +++ b/client-report/src/util/graphUtil.test.js @@ -0,0 +1,109 @@ +import graphUtil from "./graphUtil"; +import createHull from "hull.js"; +import * as d3contour from "d3-contour"; +import * as d3chromatic from "d3-scale-chromatic"; +import * as d3geo from "d3-geo"; + +Object.defineProperty(window, 'd3', { + writable: true, +}); + +global.window.d3 = { + scaleLinear: jest.fn(() => { + const mockScale = { // Create a mock scale object + domain: jest.fn(() => mockScale), // Return the mockScale itself + rangeRound: jest.fn(() => mockScale), // Return the mockScale itself + range: jest.fn(() => jest.fn()), // Add range for completeness + // Add other methods as needed (e.g., tickFormat) + }; + return mockScale; // Return the mock scale object + }), + geoPath: jest.fn(() => jest.fn()), + extent: jest.fn(() => [0, 1]), // Mock extent to return a default range + forceSimulation: jest.fn().mockReturnValue({ + force: jest.fn().mockReturnThis(), + stop: jest.fn().mockReturnThis(), + tick: jest.fn() + }), + forceX: jest.fn().mockReturnValue({ // Add a mock return for forceX + strength: jest.fn().mockReturnThis() // Add a mock for strength + }), + forceY: jest.fn(), + forceCollide: jest.fn(), + voronoi: jest.fn().mockReturnValue({ + extent: jest.fn().mockReturnThis(), + x: jest.fn().mockReturnThis(), + y: jest.fn().mockReturnThis(), + polygons: jest.fn().mockReturnValue([ + { + join: jest.fn(), + data: {} + }, + ]) + }) +} + +import * as d3 from 'd3'; + +jest.mock("hull.js"); // Mock createHull for isolation + +describe("graphUtil", () => { + it("should calculate commentsPoints with proper filtering", () => { + const mockComments = [ + { tid: 1, txt: "Comment 1" }, + { tid: 2, txt: "Comment 2" }, + { tid: 4, txt: "Comment 4" }, + ]; + const mockMath = { + pca: { "comment-projection": [[1], [2], [4]] }, + tids: [1, 2, 4], + "base-clusters": { + x: [10, 20, 30], + y: [40, 50, 60], + id: [100, 200, 300], + }, + "group-clusters": [], + }; + const mockBadTids = {}; // No badTids + + const result = graphUtil(mockComments, mockMath, mockBadTids); + + expect(result.commentsPoints.length).toBe(1); // Only 2 comments after filtering + expect(result.commentsPoints).toEqual([{"tid": 1, "txt": "Comment 1", "x": 1, "y": 2}]); + }); + + it("should calculate hulls for each group in baseClustersScaledAndGrouped", () => { + const mockCreateHull = jest.fn().mockReturnValue("Mock Hull"); + createHull.mockImplementation(mockCreateHull); // Mock createHull behavior + + const mockBaseClustersScaledAndGrouped = { + group1: [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ], + group2: [{ x: 5, y: 6 }], + }; + + const mockComments = [ + { tid: 1, txt: "Comment 1" }, + { tid: 2, txt: "Comment 2" }, + { tid: 4, txt: "Comment 4" }, + ]; + + const mockMath = { + pca: { "comment-projection": [[1], [2], [4]] }, + tids: [1, 2, 4], + "base-clusters": { + x: [10, 20, 30], + y: [40, 50, 60], + id: [100, 200, 300], + }, + "group-clusters": [], + }; + const mockBadTids = {}; // No badTids + + const result = graphUtil(mockComments, mockMath, mockBadTids); + + expect(mockCreateHull).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/client-report/src/util/net.js b/client-report/src/util/net.js index 4ac8b6bb1..fe7e27635 100644 --- a/client-report/src/util/net.js +++ b/client-report/src/util/net.js @@ -1,13 +1,11 @@ -// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +// // Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . import URLs from "./url"; -var urlPrefix = URLs.urlPrefix; - -// var pid = "unknownpid"; +const urlPrefix = URLs.urlPrefix; function polisAjax(api, data, type) { - if (!_.isString(api)) { + if (typeof api !== "string") { throw "api param should be a string"; } @@ -15,59 +13,42 @@ function polisAjax(api, data, type) { api = api.slice(1); } - var url = urlPrefix + api; - - // Add the auth token if needed. - // if (_.contains(authenticatedCalls, api)) { - // var token = tokenStore.get(); - // if (!token) { - // needAuthCallbacks.fire(); - // console.error("auth needed"); - // return $.Deferred().reject("auth needed"); - // } - // //data = $.extend({ token: token}, data); // moving to cookies - // } + let url = urlPrefix + api; - var promise; - var config = { - url: url, - contentType: "application/json; charset=utf-8", + const options = { + method: type, headers: { - //"Cache-Control": "no-cache" // no-cache - "Cache-Control": "max-age=0" + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "max-age=0", }, - xhrFields: { - withCredentials: true - }, - // crossDomain: true, - dataType: "json" + credentials: "include", // This sends cookies with the request }; - if ("GET" === type) { - promise = $.ajax($.extend(config, { - type: "GET", - data: data - })); - } else if ("POST" === type) { - promise = $.ajax($.extend(config, { - type: "POST", - data: JSON.stringify(data) - })); - } - promise.fail( function(jqXHR/*, message, errorType*/) { - - // sendEvent("Error", api, jqXHR.status); + if (type === "POST") { + options.body = JSON.stringify(data); + } else if (type === "GET" && data) { + // Add data as query parameters for GET requests + const queryParams = new URLSearchParams(data); + url += `?${queryParams}`; + } - // logger.error("SEND ERROR"); - console.dir(arguments); - if (403 === jqXHR.status) { - // eb.trigger(eb.authNeeded); - } - //logger.dir(data); - //logger.dir(message); - //logger.dir(errorType); - }); - return promise; + return fetch(url, options) + .then(response => { + if (!response.ok) { + // Handle error responses (e.g., 403) + console.error("Error:", response.status, response.statusText); + if (response.status === 403) { + // eb.trigger(eb.authNeeded); + } + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .catch(error => { + console.error("Fetch error:", error); + // Handle fetch errors + throw error; + }); } function polisPost(api, data) { @@ -83,5 +64,6 @@ const PolisNet = { polisPost: polisPost, polisGet: polisGet, }; + export default PolisNet; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..e3854eb52 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "polis", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}