diff --git a/frontend/src/js/entity-history/History.tsx b/frontend/src/js/entity-history/History.tsx index 7407c33ec68..7fafe85a8e5 100644 --- a/frontend/src/js/entity-history/History.tsx +++ b/frontend/src/js/entity-history/History.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; @@ -7,7 +7,6 @@ import { useSelector } from "react-redux"; import type { EntityInfo, - HistorySources, ResultUrlWithLabel, SelectOptionT, TimeStratifiedInfo, @@ -24,13 +23,16 @@ import { EntityHeader } from "./EntityHeader"; import InteractionControl from "./InteractionControl"; import type { LoadingPayload } from "./LoadHistoryDropzone"; import { Navigation } from "./Navigation"; -import SearchControl from "./SearchControl"; import SourcesControl from "./SourcesControl"; import { Timeline } from "./Timeline"; import VisibilityControl from "./VisibilityControl"; import { useUpdateHistorySession } from "./actions"; import { EntityId } from "./reducer"; -import { TimelineSearchProvider } from "./timelineSearchState"; +import SearchControl from "./timeline-search/SearchControl"; +import { TimelineSearchProvider } from "./timeline-search/timelineSearchState"; +import { useEntityStatus } from "./useEntityStatus"; +import { useOpenCloseInteraction } from "./useOpenCloseInteraction"; +import { useSourcesControl } from "./useSourcesControl"; const FullScreen = styled("div")` position: fixed; @@ -270,188 +272,3 @@ export const History = () => { ); }; - -export const useDefaultStatusOptions = () => { - const { t } = useTranslation(); - - return useMemo( - () => [ - { - label: t("history.options.check"), - value: t("history.options.check") as string, - }, - { - label: t("history.options.noCheck"), - value: t("history.options.noCheck") as string, - }, - ], - [t], - ); -}; - -const useEntityStatus = ({ - currentEntityId, -}: { - currentEntityId: string | null; -}) => { - const defaultStatusOptions = useDefaultStatusOptions(); - const [entityStatusOptions, setEntityStatusOptions] = - useState(defaultStatusOptions); - - const [entityIdsStatus, setEntityIdsStatus] = useState({}); - const setCurrentEntityStatus = useCallback( - (value: SelectOptionT[]) => { - if (!currentEntityId) return; - - setEntityIdsStatus((curr) => ({ - ...curr, - [currentEntityId]: value, - })); - }, - [currentEntityId], - ); - const currentEntityStatus = useMemo( - () => (currentEntityId ? entityIdsStatus[currentEntityId] || [] : []), - [currentEntityId, entityIdsStatus], - ); - - return { - entityStatusOptions, - setEntityStatusOptions, - entityIdsStatus, - setEntityIdsStatus, - currentEntityStatus, - setCurrentEntityStatus, - }; -}; - -const useSourcesControl = () => { - const [sourcesFilter, setSourcesFilter] = useState([]); - - const sources = useSelector( - (state) => state.entityHistory.defaultParams.sources, - ); - const allSourcesOptions = useMemo( - () => - sources.all.map((s) => ({ - label: s.label, - value: s.label, // Gotta use label since the value in the entity CSV is the source label as well - })), - [sources.all], - ); - const defaultSourcesOptions = useMemo( - () => - sources.default.map((s) => ({ - label: s.label, - value: s.label, // Gotta use label since the value in the entity CSV is the source label as well - })), - [sources.default], - ); - - // TODO: Figure out whether we still need the current entity unique sources - // - // const currentEntityUniqueSources = useSelector( - // (state) => state.entityHistory.currentEntityUniqueSources, - // ); - // const currentEntitySourcesOptions = useMemo( - // () => - // currentEntityUniqueSources.map((s) => ({ - // label: s, - // value: s, - // })), - // [currentEntityUniqueSources], - // ); - - const sourcesSet = useMemo( - () => new Set(sourcesFilter.map((o) => o.value as string)), - [sourcesFilter], - ); - - useEffect( - function takeDefaultIfEmpty() { - setSourcesFilter((curr) => - curr.length === 0 ? defaultSourcesOptions : curr, - ); - }, - [defaultSourcesOptions], - ); - - return { - options: allSourcesOptions, - sourcesSet, - sourcesFilter, - setSourcesFilter, - }; -}; - -const useOpenCloseInteraction = () => { - const [isOpen, setIsOpen] = useState>({}); - const isOpenRef = useRef(isOpen); - isOpenRef.current = isOpen; - - const toId = useCallback( - (year: number, quarter?: number) => `${year}-${quarter}`, - [], - ); - - const getIsOpen = useCallback( - (year: number, quarter?: number) => { - if (quarter) { - return isOpen[toId(year, quarter)]; - } else { - return [1, 2, 3, 4].every((q) => isOpen[toId(year, q)]); - } - }, - [isOpen, toId], - ); - - const toggleOpenYear = useCallback( - (year: number) => { - const quarters = [1, 2, 3, 4].map((quarter) => toId(year, quarter)); - const wasOpen = quarters.some((quarter) => isOpenRef.current[quarter]); - - setIsOpen((prev) => ({ - ...prev, - ...Object.fromEntries(quarters.map((quarter) => [quarter, !wasOpen])), - })); - }, - [toId], - ); - - const toggleOpenQuarter = useCallback( - (year: number, quarter: number) => { - const id = toId(year, quarter); - - setIsOpen((prev) => ({ ...prev, [id]: !prev[id] })); - }, - [toId], - ); - - const closeAll = useCallback(() => { - setIsOpen({}); - }, []); - - const openAll = useCallback(() => { - const lastYearsToUse = 20; - const currYear = new Date().getFullYear(); - const years = [...Array(lastYearsToUse).keys()].map((i) => currYear - i); - - const newIsOpen: Record = {}; - - for (const year of years) { - for (const quarter of [1, 2, 3, 4]) { - newIsOpen[toId(year, quarter)] = true; - } - } - - setIsOpen(newIsOpen); - }, [toId]); - - return { - getIsOpen, - toggleOpenYear, - toggleOpenQuarter, - closeAll, - openAll, - }; -}; diff --git a/frontend/src/js/entity-history/SearchEntities.tsx b/frontend/src/js/entity-history/SearchEntities.tsx index 44a36edfae0..4e1bdb4255b 100644 --- a/frontend/src/js/entity-history/SearchEntities.tsx +++ b/frontend/src/js/entity-history/SearchEntities.tsx @@ -24,8 +24,8 @@ import { MultiSelectFilterWithValueType, } from "../standard-query-editor/types"; -import { useDefaultStatusOptions } from "./History"; import { LoadingPayload } from "./LoadHistoryDropzone"; +import { useDefaultStatusOptions } from "./useDefaultStatusOptions"; export const SearchEntites = ({ onLoad, diff --git a/frontend/src/js/entity-history/TabbableTimeStratifiedInfos.tsx b/frontend/src/js/entity-history/TabbableTimeStratifiedInfos.tsx index 41ac301548a..474dc80fc5c 100644 --- a/frontend/src/js/entity-history/TabbableTimeStratifiedInfos.tsx +++ b/frontend/src/js/entity-history/TabbableTimeStratifiedInfos.tsx @@ -6,7 +6,7 @@ import SmallTabNavigation from "../small-tab-navigation/SmallTabNavigation"; import { TimeStratifiedChart } from "./TimeStratifiedChart"; import { TimeStratifiedConceptChart } from "./TimeStratifiedConceptChart"; -import { isConceptColumn, isMoneyColumn } from "./timeline/util"; +import { isConceptColumn, isMoneyColumn } from "./timeline/util/util"; const Container = styled("div")` align-self: flex-start; diff --git a/frontend/src/js/entity-history/TimeStratifiedChart.tsx b/frontend/src/js/entity-history/TimeStratifiedChart.tsx index bcb5c9c8ce5..e93825ec6ec 100644 --- a/frontend/src/js/entity-history/TimeStratifiedChart.tsx +++ b/frontend/src/js/entity-history/TimeStratifiedChart.tsx @@ -17,7 +17,7 @@ import { Bar } from "react-chartjs-2"; import { TimeStratifiedInfo } from "../api/types"; import { exists } from "../common/helpers/exists"; -import { formatCurrency } from "./timeline/util"; +import { formatCurrency } from "./timeline/util/util"; const TRUNCATE_X_AXIS_LABELS_LEN = 18; diff --git a/frontend/src/js/entity-history/Timeline.tsx b/frontend/src/js/entity-history/Timeline.tsx index dab5bdb72c1..679c8d65ef6 100644 --- a/frontend/src/js/entity-history/Timeline.tsx +++ b/frontend/src/js/entity-history/Timeline.tsx @@ -1,36 +1,20 @@ import styled from "@emotion/styled"; -import { Fragment, memo, useMemo } from "react"; +import { Fragment, memo } from "react"; import { useSelector } from "react-redux"; -import { - ColumnDescription, - ColumnDescriptionSemanticConceptColumn, - ConceptIdT, - CurrencyConfigT, - EntityInfo, - TimeStratifiedInfo, -} from "../api/types"; +import { CurrencyConfigT, EntityInfo, TimeStratifiedInfo } from "../api/types"; import type { StateT } from "../app/reducers"; -import { getConceptById } from "../concept-trees/globalTreeStoreHelper"; import { ContentFilterValue } from "./ContentControl"; import type { DetailLevel } from "./DetailControl"; import { EntityCard } from "./EntityCard"; -import { TimelineSearch } from "./TimelineSearch"; -import type { DateRow, EntityEvent, EntityHistoryStateT } from "./reducer"; +import type { EntityHistoryStateT } from "./reducer"; +import { TimelineSearch } from "./timeline-search/TimelineSearch"; +import { useTimelineSearch } from "./timeline-search/timelineSearchState"; import { TimelineEmptyPlaceholder } from "./timeline/TimelineEmptyPlaceholder"; import Year from "./timeline/Year"; -import { - isConceptColumn, - isDateColumn, - isGroupableColumn, - isIdColumn, - isMoneyColumn, - isSecondaryIdColumn, - isSourceColumn, - isVisibleColumn, -} from "./timeline/util"; -import { useTimelineSearch } from "./timelineSearchState"; +import { useColumnInformation } from "./timeline/util/useColumnInformation"; +import { useTimeBucketedSortedData } from "./timeline/util/useTimeBucketedSortedData"; const Root = styled("div")<{ isEmpty?: boolean }>` overflow-y: auto; @@ -153,412 +137,3 @@ export const Timeline = memo( ); }, ); - -const diffObjects = (objects: object[]): string[] => { - if (objects.length < 2) return []; - - const keysWithDifferentValues = new Set(); - - for (let i = 0; i < objects.length - 1; i++) { - const o1 = objects[i]; - const o2 = objects[i + 1]; - const keys = Object.keys(o1); // Assumption: all objs have same keys - - for (const key of keys) { - if ( - Object.prototype.hasOwnProperty.call(o1, key) && - Object.prototype.hasOwnProperty.call(o2, key) && - // @ts-ignore should be fine - JSON.stringify(o1[key]) !== JSON.stringify(o2[key]) - ) { - keysWithDifferentValues.add(key); - } - } - } - - return [...keysWithDifferentValues]; -}; - -const findGroupsWithinQuarter = - ( - secondaryIds: ColumnDescription[], - dateColumn: ColumnDescription, - sourceColumn: ColumnDescription, - ) => - ({ quarter, events }: { quarter: number; events: EntityEvent[] }) => { - if (events.length < 2) { - return { quarter, groupedEvents: [events], differences: [[]] }; - } - - const eventGroupBuckets: Record = {}; - - for (let i = 0; i < events.length; i++) { - const evt = events[i]; - const prevEvt = events[i - 1]; - const isDuplicateEvent = - !!evt && !!prevEvt && JSON.stringify(evt) === JSON.stringify(prevEvt); - - if (isDuplicateEvent) { - continue; - } - - const groupKey = - evt[sourceColumn.label] + - secondaryIds - .filter(isGroupableColumn) - .map(({ label }) => evt[label]) - .join(","); - - if (eventGroupBuckets[groupKey]) { - eventGroupBuckets[groupKey].push(evt); - } else { - eventGroupBuckets[groupKey] = [evt]; - } - } - - const groupedEvents = Object.values(eventGroupBuckets).map((events) => { - if (events.length > 0) { - return [ - { - ...events[0], - [dateColumn.label]: { - from: (events[0][dateColumn.label] as DateRow).from, - to: (events[events.length - 1][dateColumn.label] as DateRow).to, - }, - }, - ...events.slice(1), - ]; - } - - return events; - }); - - return { - quarter, - groupedEvents, - differences: groupedEvents.map(diffObjects), - }; - }; - -const findGroups = ( - eventsPerYears: EventsPerYear[], - secondaryIds: ColumnDescription[], - dateColumn: ColumnDescription, - sourceColumn: ColumnDescription, -) => { - const findGroupsWithinYear = ({ - year, - quarterwiseData, - }: EventsPerYear): EventsByYearWithGroups => { - return { - year, - quarterwiseData: quarterwiseData.map( - findGroupsWithinQuarter(secondaryIds, dateColumn, sourceColumn), - ), - }; - }; - - return eventsPerYears.map(findGroupsWithinYear); -}; - -interface EventsPerYear { - year: number; - quarterwiseData: { - quarter: number; - events: EntityEvent[]; - }[]; -} - -interface EventsByYearWithGroups { - year: number; - quarterwiseData: EventsByQuarterWithGroups[]; -} -export interface EventsByQuarterWithGroups { - quarter: number; - groupedEvents: EntityEvent[][]; - differences: string[][]; -} - -// Filter concepts by searchTerm -const isMatch = (str: string, searchTerm: string) => - str.toLowerCase().includes(searchTerm.toLowerCase()); - -const entryMatchesSearchTerm = ({ - entry: [key, value], - columnBuckets, - searchTerm, - rootConceptIdsByColumn, -}: { - entry: [key: string, value: unknown]; - columnBuckets: ColumnBuckets; - searchTerm: string; - rootConceptIdsByColumn: Record; -}) => { - const conceptColumn = columnBuckets.concepts.find((col) => col.label === key); - - if (conceptColumn) { - const rootConceptId = rootConceptIdsByColumn[conceptColumn.label]; - const rootConcept = getConceptById(rootConceptId, rootConceptId); - - if (!rootConcept) return false; - - const concept = getConceptById(value as string, rootConceptId); - - if (!concept) return false; - - return isMatch( - `${rootConcept.label} ${concept.label} - ${concept.description}`, - searchTerm, - ); - } - - const restColumn = columnBuckets.rest.find((col) => col.label === key); - - if (restColumn) { - return isMatch(value as string, searchTerm); - } - - const groupableColumn = columnBuckets.groupableIds.find( - (col) => - col.label === key && - !isDateColumn(col) && // Because they're already displayed somewhere else - !isSourceColumn(col), // Because they're already displayed somewhere else - ); - - if (groupableColumn) { - return isMatch(value as string, searchTerm); - } - - return false; -}; - -const groupByQuarter = ( - entityData: EntityHistoryStateT["currentEntityData"], - sources: Set, - dateColumn: ColumnDescription, - sourceColumn: ColumnDescription, - rootConceptIdsByColumn: Record, - columnBuckets: ColumnBuckets, - searchTerm?: string, -) => { - const result: { [year: string]: { [quarter: number]: EntityEvent[] } } = {}; - - // Bucket by quarter - for (const row of entityData) { - const [year, month] = (row[dateColumn.label] as DateRow).from.split("-"); - const quarter = Math.floor((parseInt(month) - 1) / 3) + 1; - - if (!result[year]) { - result[year] = { [quarter]: [] }; - } else if (!result[year][quarter]) { - result[year][quarter] = []; - } - - if (sources.has(row[sourceColumn.label] as string)) { - result[year][quarter].push(row); - } - } - - // Fill empty quarters - for (const [, quarters] of Object.entries(result)) { - for (const q of [1, 2, 3, 4]) { - if (!quarters[q]) { - quarters[q] = []; - } - } - } - - // Sort within quarter - const sortedEvents = Object.entries(result) - .sort(([yearA], [yearB]) => parseInt(yearB) - parseInt(yearA)) - .map(([year, quarterwiseData]) => ({ - year: parseInt(year), - quarterwiseData: Object.entries(quarterwiseData) - .sort(([qA], [qB]) => parseInt(qB) - parseInt(qA)) - .map(([quarter, events]) => ({ quarter: parseInt(quarter), events })), - })); - - if (sortedEvents.length === 0) { - return sortedEvents; - } - - // Fill empty years - const currentYear = new Date().getFullYear(); - while (sortedEvents[0].year < currentYear) { - sortedEvents.unshift({ - year: sortedEvents[0].year + 1, - quarterwiseData: [4, 3, 2, 1].map((q) => ({ - quarter: q, - events: [], - })), - }); - } - - const filteredSortedEvents = sortedEvents - .map(({ year, quarterwiseData }) => ({ - year, - quarterwiseData: !searchTerm - ? quarterwiseData - : quarterwiseData.map(({ quarter, events }) => ({ - quarter, - events: events.filter((event) => { - return Object.entries(event).some((entry) => - entryMatchesSearchTerm({ - entry, - columnBuckets, - rootConceptIdsByColumn, - searchTerm, - }), - ); - }), - })), - })) - .filter((year) => - !searchTerm - ? year - : year.quarterwiseData.some(({ events }) => events.length > 0), - ); - - return filteredSortedEvents; -}; - -const useTimeBucketedSortedData = ( - data: EntityHistoryStateT["currentEntityData"], - { - rootConceptIdsByColumn, - columnBuckets, - sources, - secondaryIds, - sourceColumn, - dateColumn, - }: { - rootConceptIdsByColumn: Record; - columnBuckets: ColumnBuckets; - sources: Set; - secondaryIds: ColumnDescription[]; - sourceColumn?: ColumnDescription; - dateColumn?: ColumnDescription; - }, -) => { - const { searchTerm } = useTimelineSearch(); - - return useMemo(() => { - if (!data || !dateColumn || !sourceColumn) { - return { - matches: 0, - eventsByQuarterWithGroups: [], - }; - } - - const eventsByQuarter = groupByQuarter( - data, - sources, - dateColumn, - sourceColumn, - rootConceptIdsByColumn, - columnBuckets, - searchTerm, - ); - - const eventsByQuarterWithGroups = findGroups( - eventsByQuarter, - secondaryIds, - dateColumn, - sourceColumn, - ); - - const matches = searchTerm - ? eventsByQuarterWithGroups - .flatMap(({ quarterwiseData }) => - quarterwiseData.flatMap(({ groupedEvents }) => groupedEvents), - ) - .reduce((acc, events) => acc + events.length, 0) - : 0; - - return { - matches, - eventsByQuarterWithGroups, - }; - }, [ - data, - sources, - secondaryIds, - dateColumn, - sourceColumn, - columnBuckets, - searchTerm, - rootConceptIdsByColumn, - ]); -}; - -export interface ColumnBuckets { - money: ColumnDescription[]; - concepts: ColumnDescription[]; - secondaryIds: ColumnDescription[]; - rest: ColumnDescription[]; - groupableIds: ColumnDescription[]; -} - -const useColumnInformation = () => { - const columnDescriptions = useSelector( - (state) => state.entityHistory.columnDescriptions, - ); - - const columns = useSelector( - (state) => state.entityHistory.columns, - ); - - const dateColumn = useMemo( - () => Object.values(columns).find(isDateColumn), - [columns], - ); - - const sourceColumn = useMemo( - () => Object.values(columns).find(isSourceColumn), - [columns], - ); - - const columnBuckets: ColumnBuckets = useMemo(() => { - const visibleColumnDescriptions = - columnDescriptions.filter(isVisibleColumn); - - return { - money: visibleColumnDescriptions.filter(isMoneyColumn), - concepts: visibleColumnDescriptions.filter(isConceptColumn), - secondaryIds: visibleColumnDescriptions.filter(isSecondaryIdColumn), - groupableIds: visibleColumnDescriptions.filter(isGroupableColumn), - rest: visibleColumnDescriptions.filter( - (c) => - !isMoneyColumn(c) && - (c.semantics.length === 0 || - (!isGroupableColumn(c) && !isIdColumn(c))), - ), - }; - }, [columnDescriptions]); - - const rootConceptIdsByColumn: Record = useMemo(() => { - const entries: [string, ConceptIdT][] = []; - - for (const columnDescription of columnBuckets.concepts) { - const { label, semantics } = columnDescription; - const conceptSemantic = semantics.find( - (sem): sem is ColumnDescriptionSemanticConceptColumn => - sem.type === "CONCEPT_COLUMN", - ); - - if (conceptSemantic) { - entries.push([label, conceptSemantic.concept]); - } - } - - return Object.fromEntries(entries); - }, [columnBuckets]); - - return { - columns, - columnBuckets, - dateColumn, - sourceColumn, - rootConceptIdsByColumn, - }; -}; diff --git a/frontend/src/js/entity-history/actions.ts b/frontend/src/js/entity-history/actions.ts index ad05cfead61..f237e8d6cc1 100644 --- a/frontend/src/js/entity-history/actions.ts +++ b/frontend/src/js/entity-history/actions.ts @@ -30,7 +30,7 @@ import { loadCSV, parseCSVWithHeaderToObj } from "../file/csv"; import { setMessage } from "../snack-message/actions"; import { EntityEvent, EntityId } from "./reducer"; -import { isDateColumn, isSourceColumn } from "./timeline/util"; +import { isDateColumn, isSourceColumn } from "./timeline/util/util"; export type EntityHistoryActions = ActionType< | typeof openHistory diff --git a/frontend/src/js/entity-history/SearchControl.tsx b/frontend/src/js/entity-history/timeline-search/SearchControl.tsx similarity index 87% rename from frontend/src/js/entity-history/SearchControl.tsx rename to frontend/src/js/entity-history/timeline-search/SearchControl.tsx index 67c4fab4e95..313642cd1d4 100644 --- a/frontend/src/js/entity-history/SearchControl.tsx +++ b/frontend/src/js/entity-history/timeline-search/SearchControl.tsx @@ -2,8 +2,8 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; -import IconButton from "../button/IconButton"; -import WithTooltip from "../tooltip/WithTooltip"; +import IconButton from "../../button/IconButton"; +import WithTooltip from "../../tooltip/WithTooltip"; import { useTimelineSearch } from "./timelineSearchState"; const SearchControl = () => { diff --git a/frontend/src/js/entity-history/TimelineSearch.tsx b/frontend/src/js/entity-history/timeline-search/TimelineSearch.tsx similarity index 88% rename from frontend/src/js/entity-history/TimelineSearch.tsx rename to frontend/src/js/entity-history/timeline-search/TimelineSearch.tsx index 0bb93a71f63..f4395094405 100644 --- a/frontend/src/js/entity-history/TimelineSearch.tsx +++ b/frontend/src/js/entity-history/timeline-search/TimelineSearch.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useDebounce } from "../common/helpers/useDebounce"; -import BaseInput from "../ui-components/BaseInput"; +import { useDebounce } from "../../common/helpers/useDebounce"; +import BaseInput from "../../ui-components/BaseInput"; import { useTimelineSearch } from "./timelineSearchState"; export const TimelineSearch = ({ matches }: { matches: number }) => { diff --git a/frontend/src/js/entity-history/timelineSearchState.tsx b/frontend/src/js/entity-history/timeline-search/timelineSearchState.tsx similarity index 100% rename from frontend/src/js/entity-history/timelineSearchState.tsx rename to frontend/src/js/entity-history/timeline-search/timelineSearchState.tsx diff --git a/frontend/src/js/entity-history/timeline/ConceptName.tsx b/frontend/src/js/entity-history/timeline/ConceptName.tsx index a43031ba761..966a759d840 100644 --- a/frontend/src/js/entity-history/timeline/ConceptName.tsx +++ b/frontend/src/js/entity-history/timeline/ConceptName.tsx @@ -6,7 +6,7 @@ import Highlighter from "react-highlight-words"; import { ConceptIdT, ConceptT } from "../../api/types"; import { getConceptById } from "../../concept-trees/globalTreeStoreHelper"; import FaIcon from "../../icon/FaIcon"; -import { useTimelineSearch } from "../timelineSearchState"; +import { useTimelineSearch } from "../timeline-search/timelineSearchState"; const Root = styled("div")` display: flex; diff --git a/frontend/src/js/entity-history/timeline/EventCard.tsx b/frontend/src/js/entity-history/timeline/EventCard.tsx index 5b38a80c528..ce0d50c8b97 100644 --- a/frontend/src/js/entity-history/timeline/EventCard.tsx +++ b/frontend/src/js/entity-history/timeline/EventCard.tsx @@ -17,15 +17,15 @@ import FaIcon from "../../icon/FaIcon"; import WithTooltip from "../../tooltip/WithTooltip"; import type { ContentFilterValue } from "../ContentControl"; import { RowDates } from "../RowDates"; -import { ColumnBuckets } from "../Timeline"; import type { DateRow, EntityEvent } from "../reducer"; import Highlighter from "react-highlight-words"; -import { useTimelineSearch } from "../timelineSearchState"; +import { useTimelineSearch } from "../timeline-search/timelineSearchState"; import GroupedContent from "./GroupedContent"; import { RawDataBadge } from "./RawDataBadge"; import { TinyLabel } from "./TinyLabel"; -import { isDateColumn, isSourceColumn } from "./util"; +import { ColumnBuckets } from "./util/useColumnInformation"; +import { isDateColumn, isSourceColumn } from "./util/util"; const Card = styled("div")` display: grid; diff --git a/frontend/src/js/entity-history/timeline/GroupedContent.tsx b/frontend/src/js/entity-history/timeline/GroupedContent.tsx index 12c3d7fa867..9f16b1368ab 100644 --- a/frontend/src/js/entity-history/timeline/GroupedContent.tsx +++ b/frontend/src/js/entity-history/timeline/GroupedContent.tsx @@ -20,7 +20,7 @@ import { isMoneyColumn, isSecondaryIdColumn, isVisibleColumn, -} from "./util"; +} from "./util/util"; const Grid = styled("div")` display: inline-grid; diff --git a/frontend/src/js/entity-history/timeline/Quarter.tsx b/frontend/src/js/entity-history/timeline/Quarter.tsx index d9be62ab828..281e6ccf545 100644 --- a/frontend/src/js/entity-history/timeline/Quarter.tsx +++ b/frontend/src/js/entity-history/timeline/Quarter.tsx @@ -11,8 +11,8 @@ import { import FaIcon from "../../icon/FaIcon"; import { ContentFilterValue } from "../ContentControl"; import { DetailLevel } from "../DetailControl"; -import { ColumnBuckets } from "../Timeline"; import { EntityEvent } from "../reducer"; +import { ColumnBuckets } from "./util/useColumnInformation"; import EventCard from "./EventCard"; import { SmallHeading } from "./SmallHeading"; diff --git a/frontend/src/js/entity-history/timeline/Year.tsx b/frontend/src/js/entity-history/timeline/Year.tsx index 72aaf5359ff..b508f232357 100644 --- a/frontend/src/js/entity-history/timeline/Year.tsx +++ b/frontend/src/js/entity-history/timeline/Year.tsx @@ -9,11 +9,12 @@ import { } from "../../api/types"; import { ContentFilterValue } from "../ContentControl"; import { DetailLevel } from "../DetailControl"; -import { ColumnBuckets, EventsByQuarterWithGroups } from "../Timeline"; +import { ColumnBuckets } from "./util/useColumnInformation"; -import { useTimelineSearch } from "../timelineSearchState"; +import { useTimelineSearch } from "../timeline-search/timelineSearchState"; import { Quarter } from "./Quarter"; import YearHead from "./YearHead"; +import { EventsByQuarterWithGroups } from "./util/findEventGroups"; const YearGroup = styled("div")` display: flex; diff --git a/frontend/src/js/entity-history/timeline/YearHead.tsx b/frontend/src/js/entity-history/timeline/YearHead.tsx index 70a55f7c812..dda47a35028 100644 --- a/frontend/src/js/entity-history/timeline/YearHead.tsx +++ b/frontend/src/js/entity-history/timeline/YearHead.tsx @@ -14,7 +14,7 @@ import WithTooltip from "../../tooltip/WithTooltip"; import { ConceptBubble } from "../ConceptBubble"; import { SmallHeading } from "./SmallHeading"; -import { formatCurrency, isConceptColumn, isMoneyColumn } from "./util"; +import { formatCurrency, isConceptColumn, isMoneyColumn } from "./util/util"; const Root = styled("div")` font-size: ${({ theme }) => theme.font.xs}; diff --git a/frontend/src/js/entity-history/timeline/util/findEventGroups.ts b/frontend/src/js/entity-history/timeline/util/findEventGroups.ts new file mode 100644 index 00000000000..ded3279ff63 --- /dev/null +++ b/frontend/src/js/entity-history/timeline/util/findEventGroups.ts @@ -0,0 +1,127 @@ +import { ColumnDescription } from "../../../api/types"; +import { DateRow, EntityEvent } from "../../reducer"; +import { isGroupableColumn } from "./util"; + +export interface EventsPerYear { + year: number; + quarterwiseData: { + quarter: number; + events: EntityEvent[]; + }[]; +} + +export interface EventsByYearWithGroups { + year: number; + quarterwiseData: EventsByQuarterWithGroups[]; +} +export interface EventsByQuarterWithGroups { + quarter: number; + groupedEvents: EntityEvent[][]; + differences: string[][]; +} + +const diffObjects = (objects: object[]): string[] => { + if (objects.length < 2) return []; + + const keysWithDifferentValues = new Set(); + + for (let i = 0; i < objects.length - 1; i++) { + const o1 = objects[i]; + const o2 = objects[i + 1]; + const keys = Object.keys(o1); // Assumption: all objs have same keys + + for (const key of keys) { + if ( + Object.prototype.hasOwnProperty.call(o1, key) && + Object.prototype.hasOwnProperty.call(o2, key) && + // @ts-ignore should be fine + JSON.stringify(o1[key]) !== JSON.stringify(o2[key]) + ) { + keysWithDifferentValues.add(key); + } + } + } + + return [...keysWithDifferentValues]; +}; +const findGroupsWithinQuarter = + ( + secondaryIds: ColumnDescription[], + dateColumn: ColumnDescription, + sourceColumn: ColumnDescription, + ) => + ({ quarter, events }: { quarter: number; events: EntityEvent[] }) => { + if (events.length < 2) { + return { quarter, groupedEvents: [events], differences: [[]] }; + } + + const eventGroupBuckets: Record = {}; + + for (let i = 0; i < events.length; i++) { + const evt = events[i]; + const prevEvt = events[i - 1]; + const isDuplicateEvent = + !!evt && !!prevEvt && JSON.stringify(evt) === JSON.stringify(prevEvt); + + if (isDuplicateEvent) { + continue; + } + + const groupKey = + evt[sourceColumn.label] + + secondaryIds + .filter(isGroupableColumn) + .map(({ label }) => evt[label]) + .join(","); + + if (eventGroupBuckets[groupKey]) { + eventGroupBuckets[groupKey].push(evt); + } else { + eventGroupBuckets[groupKey] = [evt]; + } + } + + const groupedEvents = Object.values(eventGroupBuckets).map((events) => { + if (events.length > 0) { + return [ + { + ...events[0], + [dateColumn.label]: { + from: (events[0][dateColumn.label] as DateRow).from, + to: (events[events.length - 1][dateColumn.label] as DateRow).to, + }, + }, + ...events.slice(1), + ]; + } + + return events; + }); + + return { + quarter, + groupedEvents, + differences: groupedEvents.map(diffObjects), + }; + }; + +export const findEventGroups = ( + eventsPerYears: EventsPerYear[], + secondaryIds: ColumnDescription[], + dateColumn: ColumnDescription, + sourceColumn: ColumnDescription, +) => { + const findGroupsWithinYear = ({ + year, + quarterwiseData, + }: EventsPerYear): EventsByYearWithGroups => { + return { + year, + quarterwiseData: quarterwiseData.map( + findGroupsWithinQuarter(secondaryIds, dateColumn, sourceColumn), + ), + }; + }; + + return eventsPerYears.map(findGroupsWithinYear); +}; diff --git a/frontend/src/js/entity-history/timeline/util/groupByQuarter.ts b/frontend/src/js/entity-history/timeline/util/groupByQuarter.ts new file mode 100644 index 00000000000..55470d76fe5 --- /dev/null +++ b/frontend/src/js/entity-history/timeline/util/groupByQuarter.ts @@ -0,0 +1,147 @@ +import { ColumnDescription, ConceptIdT } from "../../../api/types"; +import { getConceptById } from "../../../concept-trees/globalTreeStoreHelper"; +import type { DateRow, EntityEvent, EntityHistoryStateT } from "../../reducer"; +import { ColumnBuckets } from "./useColumnInformation"; +import { isDateColumn, isSourceColumn } from "./util"; + +// Filter concepts by searchTerm +const isMatch = (str: string, searchTerm: string) => + str.toLowerCase().includes(searchTerm.toLowerCase()); +const entryMatchesSearchTerm = ({ + entry: [key, value], + columnBuckets, + searchTerm, + rootConceptIdsByColumn, +}: { + entry: [key: string, value: unknown]; + columnBuckets: ColumnBuckets; + searchTerm: string; + rootConceptIdsByColumn: Record; +}) => { + const conceptColumn = columnBuckets.concepts.find((col) => col.label === key); + + if (conceptColumn) { + const rootConceptId = rootConceptIdsByColumn[conceptColumn.label]; + const rootConcept = getConceptById(rootConceptId, rootConceptId); + + if (!rootConcept) return false; + + const concept = getConceptById(value as string, rootConceptId); + + if (!concept) return false; + + return isMatch( + `${rootConcept.label} ${concept.label} - ${concept.description}`, + searchTerm, + ); + } + + const restColumn = columnBuckets.rest.find((col) => col.label === key); + + if (restColumn) { + return isMatch(value as string, searchTerm); + } + + const groupableColumn = columnBuckets.groupableIds.find( + (col) => + col.label === key && + !isDateColumn(col) && // Because they're already displayed somewhere else + !isSourceColumn(col), + ); + + if (groupableColumn) { + return isMatch(value as string, searchTerm); + } + + return false; +}; + +export const groupByQuarter = ( + entityData: EntityHistoryStateT["currentEntityData"], + sources: Set, + dateColumn: ColumnDescription, + sourceColumn: ColumnDescription, + rootConceptIdsByColumn: Record, + columnBuckets: ColumnBuckets, + searchTerm?: string, +) => { + const result: { [year: string]: { [quarter: number]: EntityEvent[] } } = {}; + + // Bucket by quarter + for (const row of entityData) { + const [year, month] = (row[dateColumn.label] as DateRow).from.split("-"); + const quarter = Math.floor((parseInt(month) - 1) / 3) + 1; + + if (!result[year]) { + result[year] = { [quarter]: [] }; + } else if (!result[year][quarter]) { + result[year][quarter] = []; + } + + if (sources.has(row[sourceColumn.label] as string)) { + result[year][quarter].push(row); + } + } + + // Fill empty quarters + for (const [, quarters] of Object.entries(result)) { + for (const q of [1, 2, 3, 4]) { + if (!quarters[q]) { + quarters[q] = []; + } + } + } + + // Sort within quarter + const sortedEvents = Object.entries(result) + .sort(([yearA], [yearB]) => parseInt(yearB) - parseInt(yearA)) + .map(([year, quarterwiseData]) => ({ + year: parseInt(year), + quarterwiseData: Object.entries(quarterwiseData) + .sort(([qA], [qB]) => parseInt(qB) - parseInt(qA)) + .map(([quarter, events]) => ({ quarter: parseInt(quarter), events })), + })); + + if (sortedEvents.length === 0) { + return sortedEvents; + } + + // Fill empty years + const currentYear = new Date().getFullYear(); + while (sortedEvents[0].year < currentYear) { + sortedEvents.unshift({ + year: sortedEvents[0].year + 1, + quarterwiseData: [4, 3, 2, 1].map((q) => ({ + quarter: q, + events: [], + })), + }); + } + + const filteredSortedEvents = sortedEvents + .map(({ year, quarterwiseData }) => ({ + year, + quarterwiseData: !searchTerm + ? quarterwiseData + : quarterwiseData.map(({ quarter, events }) => ({ + quarter, + events: events.filter((event) => { + return Object.entries(event).some((entry) => + entryMatchesSearchTerm({ + entry, + columnBuckets, + rootConceptIdsByColumn, + searchTerm, + }), + ); + }), + })), + })) + .filter((year) => + !searchTerm + ? year + : year.quarterwiseData.some(({ events }) => events.length > 0), + ); + + return filteredSortedEvents; +}; diff --git a/frontend/src/js/entity-history/timeline/util/useColumnInformation.ts b/frontend/src/js/entity-history/timeline/util/useColumnInformation.ts new file mode 100644 index 00000000000..7ef719cae04 --- /dev/null +++ b/frontend/src/js/entity-history/timeline/util/useColumnInformation.ts @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; +import { + ColumnDescription, + ColumnDescriptionSemanticConceptColumn, + ConceptIdT, +} from "../../../api/types"; +import type { StateT } from "../../../app/reducers"; +import { EntityHistoryStateT } from "../../reducer"; +import { + isConceptColumn, + isDateColumn, + isGroupableColumn, + isIdColumn, + isMoneyColumn, + isSecondaryIdColumn, + isSourceColumn, + isVisibleColumn, +} from "./util"; + +export interface ColumnBuckets { + money: ColumnDescription[]; + concepts: ColumnDescription[]; + secondaryIds: ColumnDescription[]; + rest: ColumnDescription[]; + groupableIds: ColumnDescription[]; +} +export const useColumnInformation = () => { + const columnDescriptions = useSelector( + (state) => state.entityHistory.columnDescriptions, + ); + + const columns = useSelector( + (state) => state.entityHistory.columns, + ); + + const dateColumn = useMemo( + () => Object.values(columns).find(isDateColumn), + [columns], + ); + + const sourceColumn = useMemo( + () => Object.values(columns).find(isSourceColumn), + [columns], + ); + + const columnBuckets: ColumnBuckets = useMemo(() => { + const visibleColumnDescriptions = + columnDescriptions.filter(isVisibleColumn); + + return { + money: visibleColumnDescriptions.filter(isMoneyColumn), + concepts: visibleColumnDescriptions.filter(isConceptColumn), + secondaryIds: visibleColumnDescriptions.filter(isSecondaryIdColumn), + groupableIds: visibleColumnDescriptions.filter(isGroupableColumn), + rest: visibleColumnDescriptions.filter( + (c) => + !isMoneyColumn(c) && + (c.semantics.length === 0 || + (!isGroupableColumn(c) && !isIdColumn(c))), + ), + }; + }, [columnDescriptions]); + + const rootConceptIdsByColumn: Record = useMemo(() => { + const entries: [string, ConceptIdT][] = []; + + for (const columnDescription of columnBuckets.concepts) { + const { label, semantics } = columnDescription; + const conceptSemantic = semantics.find( + (sem): sem is ColumnDescriptionSemanticConceptColumn => + sem.type === "CONCEPT_COLUMN", + ); + + if (conceptSemantic) { + entries.push([label, conceptSemantic.concept]); + } + } + + return Object.fromEntries(entries); + }, [columnBuckets]); + + return { + columns, + columnBuckets, + dateColumn, + sourceColumn, + rootConceptIdsByColumn, + }; +}; diff --git a/frontend/src/js/entity-history/timeline/util/useTimeBucketedSortedData.ts b/frontend/src/js/entity-history/timeline/util/useTimeBucketedSortedData.ts new file mode 100644 index 00000000000..de841553b10 --- /dev/null +++ b/frontend/src/js/entity-history/timeline/util/useTimeBucketedSortedData.ts @@ -0,0 +1,76 @@ +import { useMemo } from "react"; +import { ColumnDescription, ConceptIdT } from "../../../api/types"; +import { EntityHistoryStateT } from "../../reducer"; +import { useTimelineSearch } from "../../timeline-search/timelineSearchState"; +import { findEventGroups } from "./findEventGroups"; +import { groupByQuarter } from "./groupByQuarter"; +import { ColumnBuckets } from "./useColumnInformation"; + +export const useTimeBucketedSortedData = ( + data: EntityHistoryStateT["currentEntityData"], + { + rootConceptIdsByColumn, + columnBuckets, + sources, + secondaryIds, + sourceColumn, + dateColumn, + }: { + rootConceptIdsByColumn: Record; + columnBuckets: ColumnBuckets; + sources: Set; + secondaryIds: ColumnDescription[]; + sourceColumn?: ColumnDescription; + dateColumn?: ColumnDescription; + }, +) => { + const { searchTerm } = useTimelineSearch(); + + return useMemo(() => { + if (!data || !dateColumn || !sourceColumn) { + return { + matches: 0, + eventsByQuarterWithGroups: [], + }; + } + + const eventsByQuarter = groupByQuarter( + data, + sources, + dateColumn, + sourceColumn, + rootConceptIdsByColumn, + columnBuckets, + searchTerm, + ); + + const eventsByQuarterWithGroups = findEventGroups( + eventsByQuarter, + secondaryIds, + dateColumn, + sourceColumn, + ); + + const matches = searchTerm + ? eventsByQuarterWithGroups + .flatMap(({ quarterwiseData }) => + quarterwiseData.flatMap(({ groupedEvents }) => groupedEvents), + ) + .reduce((acc, events) => acc + events.length, 0) + : 0; + + return { + matches, + eventsByQuarterWithGroups, + }; + }, [ + data, + sources, + secondaryIds, + dateColumn, + sourceColumn, + columnBuckets, + searchTerm, + rootConceptIdsByColumn, + ]); +}; diff --git a/frontend/src/js/entity-history/timeline/util.ts b/frontend/src/js/entity-history/timeline/util/util.ts similarity index 100% rename from frontend/src/js/entity-history/timeline/util.ts rename to frontend/src/js/entity-history/timeline/util/util.ts diff --git a/frontend/src/js/entity-history/useDefaultStatusOptions.ts b/frontend/src/js/entity-history/useDefaultStatusOptions.ts new file mode 100644 index 00000000000..a7c73fb3824 --- /dev/null +++ b/frontend/src/js/entity-history/useDefaultStatusOptions.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +export const useDefaultStatusOptions = () => { + const { t } = useTranslation(); + + return useMemo( + () => [ + { + label: t("history.options.check"), + value: t("history.options.check") as string, + }, + { + label: t("history.options.noCheck"), + value: t("history.options.noCheck") as string, + }, + ], + [t], + ); +}; diff --git a/frontend/src/js/entity-history/useEntityStatus.ts b/frontend/src/js/entity-history/useEntityStatus.ts new file mode 100644 index 00000000000..524969eeed0 --- /dev/null +++ b/frontend/src/js/entity-history/useEntityStatus.ts @@ -0,0 +1,40 @@ +import { useCallback, useMemo, useState } from "react"; +import type { SelectOptionT } from "../api/types"; +import { EntityIdsStatus } from "./History"; +import { useDefaultStatusOptions } from "./useDefaultStatusOptions"; + +export const useEntityStatus = ({ + currentEntityId, +}: { + currentEntityId: string | null; +}) => { + const defaultStatusOptions = useDefaultStatusOptions(); + const [entityStatusOptions, setEntityStatusOptions] = + useState(defaultStatusOptions); + + const [entityIdsStatus, setEntityIdsStatus] = useState({}); + const setCurrentEntityStatus = useCallback( + (value: SelectOptionT[]) => { + if (!currentEntityId) return; + + setEntityIdsStatus((curr) => ({ + ...curr, + [currentEntityId]: value, + })); + }, + [currentEntityId], + ); + const currentEntityStatus = useMemo( + () => (currentEntityId ? entityIdsStatus[currentEntityId] || [] : []), + [currentEntityId, entityIdsStatus], + ); + + return { + entityStatusOptions, + setEntityStatusOptions, + entityIdsStatus, + setEntityIdsStatus, + currentEntityStatus, + setCurrentEntityStatus, + }; +}; diff --git a/frontend/src/js/entity-history/useOpenCloseInteraction.ts b/frontend/src/js/entity-history/useOpenCloseInteraction.ts new file mode 100644 index 00000000000..72ad23b71b9 --- /dev/null +++ b/frontend/src/js/entity-history/useOpenCloseInteraction.ts @@ -0,0 +1,73 @@ +import { useCallback, useRef, useState } from "react"; + +export const useOpenCloseInteraction = () => { + const [isOpen, setIsOpen] = useState>({}); + const isOpenRef = useRef(isOpen); + isOpenRef.current = isOpen; + + const toId = useCallback( + (year: number, quarter?: number) => `${year}-${quarter}`, + [], + ); + + const getIsOpen = useCallback( + (year: number, quarter?: number) => { + if (quarter) { + return isOpen[toId(year, quarter)]; + } else { + return [1, 2, 3, 4].every((q) => isOpen[toId(year, q)]); + } + }, + [isOpen, toId], + ); + + const toggleOpenYear = useCallback( + (year: number) => { + const quarters = [1, 2, 3, 4].map((quarter) => toId(year, quarter)); + const wasOpen = quarters.some((quarter) => isOpenRef.current[quarter]); + + setIsOpen((prev) => ({ + ...prev, + ...Object.fromEntries(quarters.map((quarter) => [quarter, !wasOpen])), + })); + }, + [toId], + ); + + const toggleOpenQuarter = useCallback( + (year: number, quarter: number) => { + const id = toId(year, quarter); + + setIsOpen((prev) => ({ ...prev, [id]: !prev[id] })); + }, + [toId], + ); + + const closeAll = useCallback(() => { + setIsOpen({}); + }, []); + + const openAll = useCallback(() => { + const lastYearsToUse = 20; + const currYear = new Date().getFullYear(); + const years = [...Array(lastYearsToUse).keys()].map((i) => currYear - i); + + const newIsOpen: Record = {}; + + for (const year of years) { + for (const quarter of [1, 2, 3, 4]) { + newIsOpen[toId(year, quarter)] = true; + } + } + + setIsOpen(newIsOpen); + }, [toId]); + + return { + getIsOpen, + toggleOpenYear, + toggleOpenQuarter, + closeAll, + openAll, + }; +}; diff --git a/frontend/src/js/entity-history/useSourcesControl.ts b/frontend/src/js/entity-history/useSourcesControl.ts new file mode 100644 index 00000000000..9efb5241d34 --- /dev/null +++ b/frontend/src/js/entity-history/useSourcesControl.ts @@ -0,0 +1,62 @@ +import { useEffect, useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import type { HistorySources, SelectOptionT } from "../api/types"; +import type { StateT } from "../app/reducers"; + +export const useSourcesControl = () => { + const [sourcesFilter, setSourcesFilter] = useState([]); + + const sources = useSelector( + (state) => state.entityHistory.defaultParams.sources, + ); + const allSourcesOptions = useMemo( + () => + sources.all.map((s) => ({ + label: s.label, + value: s.label, // Gotta use label since the value in the entity CSV is the source label as well + })), + [sources.all], + ); + const defaultSourcesOptions = useMemo( + () => + sources.default.map((s) => ({ + label: s.label, + value: s.label, // Gotta use label since the value in the entity CSV is the source label as well + })), + [sources.default], + ); + + // TODO: Figure out whether we still need the current entity unique sources + // + // const currentEntityUniqueSources = useSelector( + // (state) => state.entityHistory.currentEntityUniqueSources, + // ); + // const currentEntitySourcesOptions = useMemo( + // () => + // currentEntityUniqueSources.map((s) => ({ + // label: s, + // value: s, + // })), + // [currentEntityUniqueSources], + // ); + const sourcesSet = useMemo( + () => new Set(sourcesFilter.map((o) => o.value as string)), + [sourcesFilter], + ); + + useEffect( + function takeDefaultIfEmpty() { + setSourcesFilter((curr) => + curr.length === 0 ? defaultSourcesOptions : curr, + ); + }, + [defaultSourcesOptions], + ); + + return { + options: allSourcesOptions, + sourcesSet, + sourcesFilter, + setSourcesFilter, + }; +};