Admin.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. import React, { useState } from 'react';
  2. import { Container, Badge, FormGroup, Label, Input, Button, Collapse, Table, ListGroup, ListGroupItem } from 'reactstrap';
  3. import Paper, { Path, PaperScope, Point, Group, Color } from 'paper';
  4. import { without, append, isNil, identity, includes, is } from 'ramda';
  5. import Axios from 'axios';
  6. import VALUES, { AgeVal, GenderVal, NonNativeVal, Filters, FilterVal, FilterStatus, EducationVal } from './services';
  7. import './Admin.css';
  8. const BACKEND = process.env.REACT_APP_BACKEND || '/backend/';
  9. type PersonalInformation = {
  10. age: AgeVal,
  11. gender: GenderVal,
  12. genderCustom: string,
  13. birthPlace: string,
  14. currentPlace: string,
  15. levelEducation: EducationVal[],
  16. nonNative: NonNativeVal,
  17. }
  18. type Result = {
  19. personalInformation: PersonalInformation,
  20. canvas: OriginalCanvasData[],
  21. canvasSize: {
  22. width: number,
  23. height: number
  24. },
  25. email: string,
  26. id: number,
  27. }
  28. type StateData = {
  29. id: number,
  30. personalInformation: PersonalInformation,
  31. canvas: CanvasData[],
  32. group: paper.Group,
  33. email: string
  34. };
  35. type FormPath = {
  36. name: string,
  37. soundExample: string,
  38. associations: string[],
  39. correctness: number,
  40. friendliness: number,
  41. pleasantness: number,
  42. trustworthiness: number
  43. }
  44. type OriginalCanvasData = {
  45. form: FormPath,
  46. path: string,
  47. }
  48. type CanvasData = {
  49. form: FormPath,
  50. // path: paper.Group
  51. };
  52. type AdminState = {
  53. original: StateData[],
  54. data: StateData[],
  55. canvas?: paper.PaperScope,
  56. focusedResponse?: StateData,
  57. };
  58. const mkPathLabel = (pathName: string, path: paper.Path) => {
  59. return new Paper.PointText({
  60. fillColor: '#4b505a',
  61. justification: 'center',
  62. fontWeight: 'bold',
  63. fontSize: '16px',
  64. point: path.bounds.center,
  65. content: pathName
  66. })
  67. }
  68. const COLORS = [
  69. '#ffc6bc',
  70. '#fedea2',
  71. '#fff9b8',
  72. '#d3dbb2',
  73. '#a7d3d2',
  74. '#efe3f3',
  75. '#c4d0f5',
  76. '#c0ba98',
  77. ];
  78. const mkBgColor = (color: string) => {
  79. const _color = new Paper.Color(color);
  80. _color.alpha = 0.2;
  81. return _color;
  82. };
  83. const mkPath = (pathData: string, i: number): paper.Path => {
  84. const color = COLORS[i % COLORS.length];
  85. const _path = new Path();
  86. _path.importJSON(pathData);
  87. _path.strokeColor = new Color(color);
  88. _path.strokeWidth = 2;
  89. _path.fillColor = mkBgColor(color)
  90. return _path;
  91. }
  92. class Admin extends React.Component<{}, AdminState> {
  93. constructor(props: any) {
  94. super(props);
  95. this.state = {
  96. canvas: undefined,
  97. original: [],
  98. data: [],
  99. focusedResponse: undefined,
  100. };
  101. }
  102. mkPathGroup = (data: OriginalCanvasData[], index: number, canvas: paper.PaperScope, originalCanvas: { width: number, height: number}) => {
  103. const _data = data.reduce<(paper.Path|paper.Item)[]>((group, value, i) => {
  104. const _path = mkPath(value.path, index);
  105. _path.scale(canvas.view.viewSize.width / originalCanvas.width, new Point(0, 0));
  106. const _text = mkPathLabel(`${value.form.name} (${i})`, _path);
  107. return [
  108. ...group,
  109. _path,
  110. _text,
  111. ]
  112. }, []);
  113. const group = new Group(_data);
  114. return group;
  115. }
  116. componentDidMount = () => {
  117. const canvas = new PaperScope();
  118. canvas.setup('vom-admin-canvas');
  119. canvas.view.viewSize.height = canvas.view.size.width * 1.25;
  120. Axios.get<Result[]>(BACKEND, {
  121. headers: {
  122. 'X-Token': 'secret-potato',
  123. }
  124. }).then(response => {
  125. const _data = response.data.map((result, index) => {
  126. return {
  127. ...result,
  128. group: this.mkPathGroup(result.canvas, index, canvas, result.canvasSize),
  129. };
  130. })
  131. this.setState({
  132. canvas,
  133. original: _data,
  134. data: _data,
  135. })
  136. })
  137. }
  138. isNotEducational = (value: AgeVal | GenderVal | NonNativeVal | string | EducationVal[]): value is AgeVal | GenderVal | NonNativeVal | string => {
  139. return is(String, value);
  140. }
  141. applyFilters = (filters: Filters) => {
  142. const results = this.state.original.filter(
  143. result => {
  144. const matches = VALUES.FILTER_KEYS.map(filterKey => {
  145. const filterValues = filters[filterKey];
  146. const resultValue = result.personalInformation[filterKey];
  147. if (filterValues) {
  148. if (this.isNotEducational(resultValue)) {
  149. return !isNil(filterValues.find(v => {
  150. return v === resultValue
  151. }));
  152. } else {
  153. return filterValues.some(v => includes(v, resultValue))
  154. }
  155. } else {
  156. return true;
  157. }
  158. }).every(identity);
  159. if (matches) {
  160. result.group.visible = true;
  161. return true
  162. } else {
  163. result.group.visible = false;
  164. return false;
  165. }
  166. }
  167. )
  168. this.setState({
  169. data: results,
  170. focusedResponse: undefined,
  171. })
  172. }
  173. focusPath = (id: number) => {
  174. this.state.data.forEach(result => {
  175. if (result.id === id) {
  176. result.group.visible = true;
  177. this.setState({
  178. focusedResponse: result
  179. })
  180. } else {
  181. result.group.visible = false;
  182. }
  183. });
  184. }
  185. clearFocus = () => {
  186. this.state.data.forEach(result => {
  187. result.group.visible = true;
  188. this.setState({
  189. focusedResponse: undefined
  190. })
  191. })
  192. }
  193. render() {
  194. return (
  195. <div className="App Admin">
  196. <Container fluid>
  197. <h2>Administration panel</h2>
  198. <div className="vom-results-data">
  199. <p>total responses: <Badge color="info">{this.state.original.length}</Badge></p>
  200. <p>showing: <Badge color="info">{this.state.data.length}</Badge></p>
  201. </div>
  202. <div id="vom-results">
  203. <canvas id="vom-admin-canvas"></canvas>
  204. <div id="vom-results-panel">
  205. <FilterPanel
  206. applyFilters={this.applyFilters}
  207. ></FilterPanel>
  208. <div id="vom-focused-result" className="mt-3">
  209. {
  210. this.state.focusedResponse !== undefined && (
  211. <>
  212. <h4>Showing:</h4>
  213. <ViewResponse response={this.state.focusedResponse}/>
  214. </>
  215. )
  216. }
  217. </div>
  218. </div>
  219. <div id="vom-results-table">
  220. <h4>Results</h4>
  221. <ResultsTable
  222. data={this.state.data}
  223. focusPath={this.focusPath}
  224. clearFocus={this.clearFocus}
  225. />
  226. </div>
  227. </div>
  228. </Container>
  229. </div>
  230. )
  231. }
  232. }
  233. type TableProps = {
  234. data: StateData[],
  235. focusPath: (id: number) => void,
  236. clearFocus: () => void,
  237. };
  238. const ResultsTable: React.FunctionComponent<TableProps> = ({ data, focusPath, clearFocus }) => {
  239. const [ focused, setFocused ] = useState<number>();
  240. return (
  241. <>
  242. <div className="vom-results-table__actions">
  243. <Button onClick={() => {
  244. setFocused(undefined);
  245. clearFocus();
  246. }} outline>clear focus</Button>
  247. </div>
  248. <Table bordered className="vom-table-results">
  249. <thead>
  250. <tr>
  251. <th>#</th>
  252. <th>age</th>
  253. <th>G</th>
  254. <th>education</th>
  255. <th>birth place</th>
  256. <th>current place</th>
  257. <th>NN</th>
  258. <th colSpan={8}>map</th>
  259. </tr>
  260. <tr>
  261. <th colSpan={7}></th>
  262. <th>#</th>
  263. <th>name</th>
  264. <th>example</th>
  265. <th>associations</th>
  266. <th>C</th>
  267. <th>F</th>
  268. <th>P</th>
  269. <th>T</th>
  270. </tr>
  271. </thead>
  272. <tbody>
  273. {
  274. data.map(({ id, personalInformation, canvas }) => {
  275. return canvas.map(({ form }, i) => (
  276. <tr
  277. key={i}
  278. onClick={() => { focusPath(id); setFocused(id) }}
  279. className={
  280. `${i === 0 ? 'main-row' : ''}
  281. ${id === focused ? 'data-focused' : ''}
  282. `
  283. }
  284. >
  285. {
  286. i === 0 ? (
  287. <>
  288. <th rowSpan={canvas.length} scope="rowGroup">{id}</th>
  289. <th rowSpan={canvas.length} scope="rowGroup">{VALUES.AGE[personalInformation.age]}</th>
  290. <th rowSpan={canvas.length} scope="rowGroup">{personalInformation.gender[0]}</th>
  291. <th rowSpan={canvas.length} scope="rowGroup">{personalInformation.levelEducation.map(e => VALUES.EDUCATION[e])}</th>
  292. <th rowSpan={canvas.length} scope="rowGroup">{personalInformation.birthPlace}</th>
  293. <th rowSpan={canvas.length} scope="rowGroup">{personalInformation.currentPlace}</th>
  294. <th rowSpan={canvas.length} scope="rowGroup">{VALUES.NON_NATIVE[personalInformation.nonNative] || '-'}</th>
  295. </>
  296. ) : null
  297. }
  298. <td>{i}</td>
  299. <td>{form.name}</td>
  300. <td>{form.soundExample || '-'}</td>
  301. <td>{form.associations.join(', ')}</td>
  302. <td>{form.correctness}</td>
  303. <td>{form.friendliness}</td>
  304. <td>{form.pleasantness}</td>
  305. <td>{form.trustworthiness}</td>
  306. </tr>
  307. ))
  308. })
  309. }
  310. </tbody>
  311. </Table>
  312. </>
  313. )
  314. }
  315. const FilterPanel: React.FunctionComponent<{
  316. applyFilters: (filters: Filters) => void
  317. }> = ({ applyFilters }) => {
  318. const [filters, setFilters] = useState<Filters>({
  319. ...VALUES.FILTER,
  320. nonNative: undefined
  321. });
  322. const [activeFilters, setActiveFilters] = useState<FilterStatus>({
  323. age: true,
  324. gender: true,
  325. levelEducation: true,
  326. nonNative: false,
  327. });
  328. const isFilterActive = (field: FilterVal) => {
  329. return activeFilters[field]
  330. };
  331. const activateFilter = (field: FilterVal) => ({ target }: React.ChangeEvent<HTMLInputElement>) => {
  332. const _filters = {
  333. ...activeFilters,
  334. [field]: target.checked
  335. }
  336. if (target.checked) {
  337. handleFilter({
  338. ...filters,
  339. [field]: VALUES.FILTER[field]
  340. });
  341. } else {
  342. handleFilter({
  343. ...filters,
  344. [field]: undefined
  345. })
  346. }
  347. setActiveFilters(_filters);
  348. }
  349. const selectAll = () => {
  350. handleFilter({
  351. ...VALUES.FILTER,
  352. nonNative: undefined
  353. })
  354. }
  355. const isChecked = (field: FilterVal, value: string) => {
  356. const values = filters[field];
  357. if (values){
  358. return includes(value, values);
  359. } else {
  360. return false
  361. }
  362. }
  363. const handleCheck = (field: FilterVal, value: string) => ({ target }: React.ChangeEvent<HTMLInputElement>) => {
  364. const _filters = {
  365. ...filters,
  366. [field]: target.checked ? append(value, filters[field] || []) : without([ value ], filters[field] || [])
  367. };
  368. handleFilter(_filters)
  369. }
  370. const handleFilter = (_filters: Filters) => {
  371. applyFilters(_filters);
  372. setFilters(_filters)
  373. }
  374. return (
  375. <div id="vom-results-filters">
  376. <h4>
  377. Filters
  378. <div id="vom-filter-actions">
  379. <Button outline size="sm" color="secondary" onClick={selectAll}>Select all</Button>
  380. <Button outline size="sm" color="secondary" onClick={() => handleFilter({ ...VALUES.CLEAN_FILTER })} disabled>Clear all</Button>
  381. </div>
  382. </h4>
  383. <Switch id="age-switch" checked={isFilterActive('age')} onChange={activateFilter('age')}>
  384. <h6>
  385. Age
  386. </h6>
  387. </Switch>
  388. <Collapse isOpen={isFilterActive('age')}>
  389. <FormGroup className="vom-filter-group">
  390. {
  391. (Object.keys(VALUES.AGE) as AgeVal[]).map(value => (
  392. <FormGroup check inline key={value}>
  393. <Label check>
  394. <Input type="checkbox" checked={isChecked('age', value)} onChange={handleCheck('age', value)}/>{VALUES.AGE[value]}
  395. </Label>
  396. </FormGroup>
  397. ))
  398. }
  399. </FormGroup>
  400. </Collapse>
  401. <hr></hr>
  402. <Switch id="gender-switch" checked={isFilterActive('gender')} onChange={activateFilter('gender')}>
  403. <h6>Gender</h6>
  404. </Switch>
  405. <Collapse isOpen={isFilterActive('gender')}>
  406. <FormGroup className="vom-filter-group">
  407. {
  408. VALUES.GENDER.map(value => (
  409. <FormGroup check inline key={value}>
  410. <Label check>
  411. <Input disabled={!isFilterActive('gender')} type="checkbox" checked={isChecked('gender', value)} onChange={handleCheck('gender', value)}/>{value}
  412. </Label>
  413. </FormGroup>
  414. ))
  415. }
  416. </FormGroup>
  417. </Collapse>
  418. <hr/>
  419. <Switch id="education-switch" checked={isFilterActive('levelEducation')} onChange={activateFilter('levelEducation')}>
  420. <h6>Level of education</h6>
  421. </Switch>
  422. <Collapse isOpen={isFilterActive('levelEducation')}>
  423. <FormGroup className="vom-filter-group">
  424. {
  425. (Object.keys(VALUES.EDUCATION) as EducationVal[]).map(value => (
  426. <FormGroup check inline key={value}>
  427. <Label check>
  428. <Input disabled={!isFilterActive('levelEducation')} type="checkbox" checked={isChecked('levelEducation', value)} onChange={handleCheck('levelEducation', value)}/>
  429. {VALUES.EDUCATION[value]}
  430. </Label>
  431. </FormGroup>
  432. ))
  433. }
  434. </FormGroup>
  435. </Collapse>
  436. <hr />
  437. <Switch id="non-native-switch" checked={isFilterActive('nonNative')} onChange={activateFilter('nonNative')}>
  438. <h6> Non natives </h6>
  439. </Switch>
  440. <Collapse isOpen={isFilterActive('nonNative')}>
  441. <FormGroup className="vom-filter-group">
  442. {
  443. (Object.keys(VALUES.NON_NATIVE) as NonNativeVal[]).map(value => (
  444. <FormGroup check inline key={value}>
  445. <Label check>
  446. <Input disabled={!isFilterActive('nonNative')} type="checkbox" checked={isChecked('nonNative', value)} onChange={handleCheck('nonNative', value)}/>{VALUES.NON_NATIVE[value]}
  447. </Label>
  448. </FormGroup>
  449. ))
  450. }
  451. </FormGroup>
  452. </Collapse>
  453. </div>
  454. )
  455. }
  456. const Switch: React.FunctionComponent<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>> = ({ id, children , ...props}) => {
  457. return (
  458. <div className="custom-control custom-switch">
  459. <input type="checkbox" className="custom-control-input" id={id} {...props} />
  460. <label className="custom-control-label" htmlFor={id}>
  461. { children }
  462. </label>
  463. </div>
  464. )
  465. };
  466. const ViewResponse: React.FunctionComponent<{response: StateData}> = ({ response }) => {
  467. const {
  468. personalInformation,
  469. email,
  470. canvas
  471. } = response;
  472. return (
  473. <div className="vom-view-response">
  474. <ListGroup>
  475. <ListGroupItem>
  476. <b>Age</b>: {VALUES.AGE[personalInformation.age]}
  477. </ListGroupItem>
  478. <ListGroupItem>
  479. <b>Gender</b>: {personalInformation.gender}
  480. </ListGroupItem>
  481. <ListGroupItem>
  482. <b>Education</b>: {personalInformation.levelEducation.map(e => VALUES.EDUCATION[e]).join(', ')}
  483. </ListGroupItem>
  484. <ListGroupItem>
  485. <b>Birth Place</b>: {personalInformation.birthPlace}
  486. </ListGroupItem>
  487. <ListGroupItem>
  488. <b>Current Place</b>: {personalInformation.currentPlace}
  489. </ListGroupItem>
  490. <ListGroupItem>
  491. <b>Non Native?</b>: {VALUES.NON_NATIVE[personalInformation.nonNative] || '-'}
  492. </ListGroupItem>
  493. <ListGroupItem>
  494. <b>email</b>: {email || '-'}
  495. </ListGroupItem>
  496. {
  497. canvas.map(({form}, i) => (
  498. <ListGroupItem key={i}>
  499. <b>Accent name</b>: {form.name} ({i})<br/>
  500. <b>Example</b>: {form.soundExample} <br/>
  501. <b>Associations</b>: {form.associations.join(', ')} <br/>
  502. <b>Correctness</b>: {form.correctness} <br/>
  503. <b>Friendliness</b>: {form.friendliness} <br/>
  504. <b>Pleasantness</b>: {form.pleasantness} <br/>
  505. <b>Trustworthiness</b>: {form.trustworthiness} <br/>
  506. </ListGroupItem>
  507. ))
  508. }
  509. </ListGroup>
  510. </div>
  511. )
  512. }
  513. export default Admin;