From 770e03431df05abb59af392ba37503c86c51a6cf Mon Sep 17 00:00:00 2001 From: "Huy Tran (Harry)" Date: Mon, 20 Dec 2021 00:43:36 +1000 Subject: [PATCH] feat: add capture tags to Captures table (#237) * feat: add capture tags to Captures table * feat(captures table): add capture tags to captures table * fix: ensure tag lookup is populated Co-authored-by: Nick Charlton --- cypress/component/FilterTop.spec.py.js | 3 - package-lock.json | 5 -- src/api/treeTrackerApi.js | 24 ++++-- src/components/CaptureFilter.js | 14 ++-- src/components/CaptureTags.js | 38 ++------- src/components/Captures/CaptureTable.js | 86 +++++++++++++++++--- src/components/Captures/CaptureTable.test.js | 34 +++++++- src/components/FilterTop.js | 14 ++-- src/components/tests/fixtures.js | 64 ++++++++------- src/components/tests/tags.test.js | 7 -- src/context/CapturesContext.js | 8 ++ src/context/TagsContext.js | 31 +++---- 12 files changed, 195 insertions(+), 133 deletions(-) diff --git a/cypress/component/FilterTop.spec.py.js b/cypress/component/FilterTop.spec.py.js index d18aecca9..78c56dfbf 100644 --- a/cypress/component/FilterTop.spec.py.js +++ b/cypress/component/FilterTop.spec.py.js @@ -24,9 +24,6 @@ describe('FilterTop', () => { state: { tagList: [], }, - effects: { - getTags(_payload, _state) {}, - }, }, organizations: { state: { diff --git a/package-lock.json b/package-lock.json index 01af0e429..eb8197d51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27369,11 +27369,6 @@ "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, "engines": { "node": ">=0.10.0" } diff --git a/src/api/treeTrackerApi.js b/src/api/treeTrackerApi.js index a320b7bf8..40df0ba3e 100644 --- a/src/api/treeTrackerApi.js +++ b/src/api/treeTrackerApi.js @@ -280,10 +280,8 @@ export default { /* * get tag list */ - getTags(filter, abortController) { - const filterString = - `filter[limit]=25&` + - (filter ? `filter[where][tagName][ilike]=${filter}%` : ''); + getTags(abortController) { + const filterString = `filter[order]=tagName`; const query = `${process.env.REACT_APP_API_ROOT}/api/tags?${filterString}`; return fetch(query, { method: 'GET', @@ -355,10 +353,20 @@ export default { /* * get tags for a given tree */ - getCaptureTags({ captureId, tagId }) { - const filterString = - (captureId ? `filter[where][treeId]=${captureId}` : '') + - (tagId ? `&filter[where][tagId]=${tagId}` : ''); + getCaptureTags({ captureIds, tagIds }) { + const useAnd = captureIds && tagIds; + const captureIdClauses = (captureIds || []).map( + (id, index) => + `filter[where]${useAnd ? '[and][0]' : ''}[or][${index}][treeId]=${id}`, + ); + const tagIdClauses = (tagIds || []).map( + (id, index) => + `filter[where][and]${ + useAnd ? '[and][1]' : '' + }[or][${index}][tagId]=${id}`, + ); + + const filterString = [...captureIdClauses, ...tagIdClauses].join('&'); const query = `${process.env.REACT_APP_API_ROOT}/api/tree_tags?${filterString}`; return fetch(query, { method: 'GET', diff --git a/src/components/CaptureFilter.js b/src/components/CaptureFilter.js index 32cb42022..42e805b83 100644 --- a/src/components/CaptureFilter.js +++ b/src/components/CaptureFilter.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useState, useContext } from 'react'; import { withStyles } from '@material-ui/core/styles'; import Grid from '@material-ui/core/Grid'; import Button from '@material-ui/core/Button'; @@ -101,12 +101,6 @@ function Filter(props) { ); const [tokenId, setTokenId] = useState(filter?.tokenId || filterOptionAll); - useEffect(() => { - const abortController = new AbortController(); - tagsContext.getTags(tagSearchString, { signal: abortController.signal }); - return () => abortController.abort(); - }, [tagSearchString]); - const handleDateStartChange = (date) => { setDateStart(date); }; @@ -360,7 +354,11 @@ function Filter(props) { // active: true, // public: true, // }, - ...tagsContext.tagList, + ...tagsContext.tagList.filter((t) => + t.tagName + .toLowerCase() + .startsWith(tagSearchString.toLowerCase()), + ), ]} value={tag} defaultValue={'Not set'} diff --git a/src/components/CaptureTags.js b/src/components/CaptureTags.js index f8e7f078c..103bf1b51 100644 --- a/src/components/CaptureTags.js +++ b/src/components/CaptureTags.js @@ -1,10 +1,9 @@ -import React, { useState, useCallback, useContext } from 'react'; +import React, { useState, useContext } from 'react'; import ChipInput from 'material-ui-chip-input'; import Autosuggest from 'react-autosuggest'; import MenuItem from '@material-ui/core/MenuItem'; import Paper from '@material-ui/core/Paper'; import { makeStyles } from '@material-ui/core/styles'; -import * as _ from 'lodash'; import { TagsContext } from '../context/TagsContext'; const useStyles = makeStyles((theme) => ({ @@ -67,21 +66,12 @@ function getSuggestionValue(suggestion) { return suggestion.tagName; } -// This is needed to pass the same function to debounce() each time -const debounceCallback = ({ value, callback }) => { - return callback && callback(value); -}; - const CaptureTags = (props) => { // console.log('render: capture tags'); const classes = useStyles(props); const tagsContext = useContext(TagsContext); const [textFieldInput, setTextFieldInput] = useState(''); const [error, setError] = useState(false); - const debouncedInputHandler = useCallback( - _.debounce(debounceCallback, 250), - [], - ); const TAG_PATTERN = '^\\w*$'; function renderInput(inputProps) { @@ -113,34 +103,20 @@ const CaptureTags = (props) => { const isValidTagString = (value) => RegExp(TAG_PATTERN).test(value); - let handleSuggestionsFetchRequested = ({ value }) => { - debouncedInputHandler({ - value, - callback: (val) => { - if (isValidTagString(val)) { - return tagsContext.getTags(val); - } - return null; - }, - }); - }; - - let handleSuggestionsClearRequested = () => {}; - - let handletextFieldInputChange = (event, { newValue }) => { + const handletextFieldInputChange = (event, { newValue }) => { setTextFieldInput(newValue); setError(!isValidTagString(newValue)); }; - let handleBeforeAddChip = () => { + const handleBeforeAddChip = () => { return !error; }; - let handleAddChip = (chip) => { + const handleAddChip = (chip) => { tagsContext.setTagInput(tagsContext.tagInput.concat([chip])); }; - let handleDeleteChip = (_chip, index) => { + const handleDeleteChip = (_chip, index) => { const temp = tagsContext.tagInput; temp.splice(index, 1); tagsContext.setTagInput(temp); @@ -164,8 +140,8 @@ const CaptureTags = (props) => { !tagsContext.tagInput.find((i) => i.toLowerCase() === tagName) ); })} - onSuggestionsFetchRequested={handleSuggestionsFetchRequested} - onSuggestionsClearRequested={handleSuggestionsClearRequested} + onSuggestionsFetchRequested={() => {}} + onSuggestionsClearRequested={() => {}} renderSuggestionsContainer={renderSuggestionsContainer} getSuggestionValue={getSuggestionValue} renderSuggestion={renderSuggestion} diff --git a/src/components/Captures/CaptureTable.js b/src/components/Captures/CaptureTable.js index 2aa9c626f..e9d655755 100644 --- a/src/components/Captures/CaptureTable.js +++ b/src/components/Captures/CaptureTable.js @@ -22,6 +22,8 @@ import { tokenizationStates } from '../../common/variables'; import useStyle from './CaptureTable.styles.js'; import ExportCaptures from 'components/ExportCaptures'; import { CaptureDetailProvider } from '../../context/CaptureDetailContext'; +import { TagsContext } from 'context/TagsContext'; +import api from '../../api/treeTrackerApi'; const columns = [ { @@ -62,6 +64,11 @@ const columns = [ renderer: (val) => val ? tokenizationStates.TOKENIZED : tokenizationStates.NOT_TOKENIZED, }, + { + attr: 'captureTags', + label: 'Capture Tags', + noSort: true, + }, { attr: 'timeCreated', label: 'Created', @@ -86,21 +93,58 @@ const CaptureTable = () => { getCaptureAsync, } = useContext(CapturesContext); const speciesContext = useContext(SpeciesContext); + const tagsContext = useContext(TagsContext); const [isDetailsPaneOpen, setIsDetailsPaneOpen] = useState(false); - const [speciesState, setSpeciesState] = useState({}); + const [speciesLookup, setSpeciesLookup] = useState({}); + const [tagLookup, setTagLookup] = useState({}); + const [captureTagLookup, setCaptureTagLookup] = useState({}); const [isOpenExport, setOpenExport] = useState(false); const classes = useStyle(); useEffect(() => { - formatSpeciesData(); - }, [filter]); + populateSpeciesLookup(); + }, [speciesContext.speciesList]); - const formatSpeciesData = async () => { + useEffect(() => { + populateTagLookup(); + }, [tagsContext.tagList]); + + useEffect(async () => { + // Don't do anything if there are no captures + if (!captures?.length) { + return; + } + + // Get the capture tags for all of the displayed captures + const captureTags = await api.getCaptureTags({ + captureIds: captures.map((c) => c.id), + }); + + // Populate a lookup for quick access when rendering the table + let lookup = {}; + captureTags.forEach((captureTag) => { + if (!lookup[captureTag.treeId]) { + lookup[captureTag.treeId] = []; + } + lookup[captureTag.treeId].push(tagLookup[captureTag.tagId]); + }); + setCaptureTagLookup(lookup); + }, [captures, tagLookup]); + + const populateSpeciesLookup = async () => { let species = {}; - speciesContext.speciesList.map((s) => { + speciesContext.speciesList.forEach((s) => { species[s.id] = s.name; }); - setSpeciesState(species); + setSpeciesLookup(species); + }; + + const populateTagLookup = async () => { + let tags = {}; + tagsContext.tagList.forEach((t) => { + tags[t.id] = t.tagName; + }); + setTagLookup(tags); }; const toggleDrawer = (id) => { @@ -179,7 +223,7 @@ const CaptureTable = () => { handleClose={() => setOpenExport(false)} columns={columns} filter={filter} - speciesState={speciesState} + speciesLookup={speciesLookup} /> {tablePagination()} @@ -214,7 +258,13 @@ const CaptureTable = () => { > {columns.map(({ attr, renderer }) => ( - {formatCell(capture, speciesState, attr, renderer)} + {formatCell( + capture, + speciesLookup, + captureTagLookup[capture.id] || [], + attr, + renderer, + )} ))} @@ -233,7 +283,13 @@ const CaptureTable = () => { ); }; -export const formatCell = (capture, speciesState, attr, renderer) => { +export const formatCell = ( + capture, + speciesLookup, + additionalTags, + attr, + renderer, +) => { if (attr === 'id' || attr === 'planterId') { return ( { /> ); } else if (attr === 'speciesId') { - return capture[attr] === null ? '--' : speciesState[capture[attr]]; + return capture[attr] === null ? '--' : speciesLookup[capture[attr]]; } else if (attr === 'verificationStatus') { return capture['active'] === null || capture['approved'] === null ? '--' : getVerificationStatus(capture['active'], capture['approved']); + } else if (attr === 'captureTags') { + return [ + capture.age, + capture.morphology, + capture.captureApprovalTag, + capture.rejectionReason, + ...additionalTags, + ] + .filter((tag) => tag !== null) + .join(', '); } else { return renderer ? renderer(capture[attr]) : capture[attr]; } diff --git a/src/components/Captures/CaptureTable.test.js b/src/components/Captures/CaptureTable.test.js index 7dcba22da..02f96c6dd 100644 --- a/src/components/Captures/CaptureTable.test.js +++ b/src/components/Captures/CaptureTable.test.js @@ -12,6 +12,7 @@ import axios from 'axios'; import theme from '../common/theme'; import { ThemeProvider } from '@material-ui/core/styles'; import { SpeciesContext } from '../../context/SpeciesContext'; +import { TagsContext } from '../../context/TagsContext'; import { CapturesContext, CapturesProvider, @@ -22,9 +23,10 @@ import FilterModel from '../../models/Filter'; import * as loglevel from 'loglevel'; import { CAPTURES, - SPECIES, + CAPTURE_TAGS, capturesValues, speciesValues, + tagsValues, } from '../tests/fixtures'; const log = loglevel.getLogger('../models/captures.test'); @@ -35,13 +37,23 @@ describe('Captures', () => { let component; let data = CAPTURES; + // mock the treeTrackerApi + const captureApi = require('../../api/treeTrackerApi').default; + + captureApi.getCaptureTags = () => { + log.debug(`mock getCaptureTags: ${CAPTURE_TAGS}`); + return Promise.resolve(CAPTURE_TAGS); + }; + describe('CapturesTable renders properly', () => { beforeEach(async () => { component = ( - + + + @@ -72,11 +84,11 @@ describe('Captures', () => { expect(arr[1]).toBe('1-4 of 4'); }); - it('should have 8 headers', () => { + it('should have 9 headers', () => { const table = screen.getByRole(/table/i); const headers = within(table).getAllByRole(/columnheader/i); const arr = headers.map((header) => header.textContent); - expect(arr).toHaveLength(8); + expect(arr).toHaveLength(9); }); it('renders headers for captures table', () => { @@ -97,6 +109,8 @@ describe('Captures', () => { expect(item).toBeInTheDocument(); item = within(table).getByText(/Token Status/i); expect(item).toBeInTheDocument(); + item = within(table).getByText(/Capture Tags/i); + expect(item).toBeInTheDocument(); item = within(table).getByText(/Created/i); expect(item).toBeInTheDocument(); }); @@ -127,6 +141,8 @@ describe('Captures', () => { expect(tokens).toHaveLength(4); const device = within(table).getAllByText(/1-abcdef123456/i); expect(device).toHaveLength(1); + const captureTag = within(table).getAllByText(/tag_c/i); + expect(captureTag).toHaveLength(4); }); }); @@ -171,6 +187,16 @@ describe('Captures', () => { .mockReturnValueOnce({ data }); }); + context.queryCapturesApi = jest.fn(() => { + console.log('mock queryCapturesApi'); + // return Promise.resolve({ data }); + return axios.get + .mockReturnValueOnce({ + data: { count: data.length }, + }) + .mockReturnValueOnce({ data }); + }); + beforeEach(async () => { component = ( diff --git a/src/components/FilterTop.js b/src/components/FilterTop.js index 32cb42022..42e805b83 100644 --- a/src/components/FilterTop.js +++ b/src/components/FilterTop.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useState, useContext } from 'react'; import { withStyles } from '@material-ui/core/styles'; import Grid from '@material-ui/core/Grid'; import Button from '@material-ui/core/Button'; @@ -101,12 +101,6 @@ function Filter(props) { ); const [tokenId, setTokenId] = useState(filter?.tokenId || filterOptionAll); - useEffect(() => { - const abortController = new AbortController(); - tagsContext.getTags(tagSearchString, { signal: abortController.signal }); - return () => abortController.abort(); - }, [tagSearchString]); - const handleDateStartChange = (date) => { setDateStart(date); }; @@ -360,7 +354,11 @@ function Filter(props) { // active: true, // public: true, // }, - ...tagsContext.tagList, + ...tagsContext.tagList.filter((t) => + t.tagName + .toLowerCase() + .startsWith(tagSearchString.toLowerCase()), + ), ]} value={tag} defaultValue={'Not set'} diff --git a/src/components/tests/fixtures.js b/src/components/tests/fixtures.js index 3695fcfd6..40697995c 100644 --- a/src/components/tests/fixtures.js +++ b/src/components/tests/fixtures.js @@ -38,13 +38,6 @@ const CAPTURES = [ morphology: 'seedling', age: 'new_tree', captureApprovalTag: 'simple_leaf', - treeTags: [ - { - id: 1, - treeId: 0, - tagId: 3, - }, - ], }, { id: 110, @@ -60,13 +53,6 @@ const CAPTURES = [ morphology: 'seedling', age: 'new_tree', captureApprovalTag: 'simple_leaf', - treeTags: [ - { - id: 1, - treeId: 0, - tagId: 3, - }, - ], }, { id: 120, @@ -82,13 +68,6 @@ const CAPTURES = [ morphology: 'seedling', age: 'new_tree', captureApprovalTag: 'simple_leaf', - treeTags: [ - { - id: 1, - treeId: 0, - tagId: 3, - }, - ], }, { id: 101, @@ -104,13 +83,29 @@ const CAPTURES = [ morphology: 'seedling', age: 'new_tree', captureApprovalTag: 'simple_leaf', - treeTags: [ - { - id: 1, - treeId: 0, - tagId: 3, - }, - ], + }, +]; + +const CAPTURE_TAGS = [ + { + id: 1, + treeId: 100, + tagId: 3, + }, + { + id: 1, + treeId: 110, + tagId: 3, + }, + { + id: 1, + treeId: 120, + tagId: 3, + }, + { + id: 1, + treeId: 101, + tagId: 3, }, ]; @@ -183,13 +178,19 @@ const TAG = { const TAGS = [ { id: 0, - tagName: 'tag_b', + tagName: 'tag_a', public: true, active: true, }, { id: 1, - tagName: 'tag_a', + tagName: 'tag_b', + public: true, + active: true, + }, + { + id: 3, + tagName: 'tag_c', public: true, active: true, }, @@ -276,7 +277,7 @@ const verifyValues = { const tagsValues = { tagList: TAGS, tagInput: [], - getTags: () => {}, + loadTags: () => {}, createTags: () => {}, setTagInput: () => {}, }; @@ -298,6 +299,7 @@ const speciesValues = { module.exports = { CAPTURE, CAPTURES, + CAPTURE_TAGS, GROWER, GROWERS, ORGS, diff --git a/src/components/tests/tags.test.js b/src/components/tests/tags.test.js index 5dd5a0ee2..e64a206de 100644 --- a/src/components/tests/tags.test.js +++ b/src/components/tests/tags.test.js @@ -96,13 +96,6 @@ describe('tags', () => { const text = screen.getByText('testTag'); expect(text).toBeTruthy(); }); - - it('api.getTags should be called', async () => { - console.log('mock calls -- ', api.getTags.mock.calls); - await waitFor(() => expect(api.getTags).toHaveBeenCalledTimes(3)); - expect(api.getTags.mock.calls[1][0]).toBe('searchTag'); - // expect(api.getTags.mock.calls[2][0]).toBe('testTag'); - }); }); //}}} diff --git a/src/context/CapturesContext.js b/src/context/CapturesContext.js index ae659c36e..9e3380d8e 100644 --- a/src/context/CapturesContext.js +++ b/src/context/CapturesContext.js @@ -124,6 +124,10 @@ export function CapturesProvider(props) { deviceIdentifier: true, speciesId: true, tokenId: true, + age: true, + morphology: true, + captureApprovalTag: true, + rejectionReason: true, }, }; @@ -156,6 +160,10 @@ export function CapturesProvider(props) { deviceIdentifier: true, speciesId: true, tokenId: true, + age: true, + morphology: true, + captureApprovalTag: true, + rejectionReason: true, }, }; diff --git a/src/context/TagsContext.js b/src/context/TagsContext.js index f0b11672e..393736e48 100644 --- a/src/context/TagsContext.js +++ b/src/context/TagsContext.js @@ -1,5 +1,4 @@ -import React, { useState, createContext } from 'react'; -import * as _ from 'lodash'; +import React, { useState, createContext, useEffect } from 'react'; import api from '../api/treeTrackerApi'; import * as loglevel from 'loglevel'; @@ -8,7 +7,7 @@ const log = loglevel.getLogger('../context/TagsContext'); export const TagsContext = createContext({ tagList: [], tagInput: [], - getTags: () => {}, + loadTags: () => {}, createTags: () => {}, setTagInput: () => {}, }); @@ -17,21 +16,17 @@ export function TagsProvider(props) { const [tagList, setTagList] = useState([]); const [tagInput, setTagInput] = useState([]); - // STATE HELPER FUNCTIONS - - const appendToTagList = (tags) => { - const sortedTagList = _.unionBy(tagList, tags, 'id').sort((a, b) => - a.tagName.localeCompare(b.tagName), - ); - setTagList(sortedTagList); - }; + useEffect(() => { + const abortController = new AbortController(); + loadTags({ signal: abortController.signal }); + return () => abortController.abort(); + }, []); // EVENT HANDLERS - - const getTags = async (filter, abortController) => { - const newTags = await api.getTags(filter, abortController); - log.debug('load (more) tags from api:', newTags.length); - appendToTagList(newTags); + const loadTags = async () => { + const tags = await api.getTags(); + log.debug('load tags from api:', tags.length); + setTagList(tags); }; /* * check for new tags in tagInput and add them to the database @@ -41,13 +36,13 @@ export function TagsProvider(props) { return api.createTag(t); }); - return Promise.all(savedTags); + return Promise.all(savedTags).then(loadTags); }; const value = { tagList, tagInput, - getTags, + loadTags, createTags, setTagInput, };