From 1c93e61136cd3b3dbf54bd423f52437c7a32b441 Mon Sep 17 00:00:00 2001 From: Kai Rollmann Date: Mon, 24 Jun 2024 14:31:21 +0200 Subject: [PATCH] Reorganize timeline data logic and search logic --- frontend/src/js/entity-history/History.tsx | 195 +------- .../src/js/entity-history/SearchEntities.tsx | 2 +- .../TabbableTimeStratifiedInfos.tsx | 2 +- .../js/entity-history/TimeStratifiedChart.tsx | 2 +- frontend/src/js/entity-history/Timeline.tsx | 439 +----------------- frontend/src/js/entity-history/actions.ts | 2 +- .../{ => timeline-search}/SearchControl.tsx | 4 +- .../{ => timeline-search}/TimelineSearch.tsx | 4 +- .../timelineSearchState.tsx | 0 .../entity-history/timeline/ConceptName.tsx | 2 +- .../js/entity-history/timeline/EventCard.tsx | 6 +- .../timeline/GroupedContent.tsx | 4 +- .../js/entity-history/timeline/Quarter.tsx | 2 +- .../src/js/entity-history/timeline/Year.tsx | 5 +- .../js/entity-history/timeline/YearHead.tsx | 2 +- .../src/js/entity-history/timeline/util.ts | 35 -- .../entity-history/useDefaultStatusOptions.ts | 20 + .../src/js/entity-history/useEntityStatus.ts | 40 ++ .../entity-history/useOpenCloseInteraction.ts | 73 +++ .../js/entity-history/useSourcesControl.ts | 62 +++ 20 files changed, 227 insertions(+), 674 deletions(-) rename frontend/src/js/entity-history/{ => timeline-search}/SearchControl.tsx (87%) rename frontend/src/js/entity-history/{ => timeline-search}/TimelineSearch.tsx (88%) rename frontend/src/js/entity-history/{ => timeline-search}/timelineSearchState.tsx (100%) delete mode 100644 frontend/src/js/entity-history/timeline/util.ts create mode 100644 frontend/src/js/entity-history/useDefaultStatusOptions.ts create mode 100644 frontend/src/js/entity-history/useEntityStatus.ts create mode 100644 frontend/src/js/entity-history/useOpenCloseInteraction.ts create mode 100644 frontend/src/js/entity-history/useSourcesControl.ts 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..38422dab824 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/lib/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..73c836d67e7 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/lib/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..4a0fc690b87 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/lib/useColumnInformation"; +import { useTimeBucketedSortedData } from "./timeline/lib/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..10fe647b128 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/lib/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..058222632ba 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 "./lib/useColumnInformation"; +import { isDateColumn, isSourceColumn } from "./lib/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..ea99dd5a2fb 100644 --- a/frontend/src/js/entity-history/timeline/GroupedContent.tsx +++ b/frontend/src/js/entity-history/timeline/GroupedContent.tsx @@ -13,14 +13,14 @@ import { DateRow, EntityEvent } from "../reducer"; import { formatHistoryDayRange } from "../RowDates"; import ConceptName from "./ConceptName"; -import { TinyLabel } from "./TinyLabel"; import { isConceptColumn, isDateColumn, isMoneyColumn, isSecondaryIdColumn, isVisibleColumn, -} from "./util"; +} from "./lib/util"; +import { TinyLabel } from "./TinyLabel"; 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..f5c585a13e0 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 "./lib/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..4a94be43024 100644 --- a/frontend/src/js/entity-history/timeline/Year.tsx +++ b/frontend/src/js/entity-history/timeline/Year.tsx @@ -9,9 +9,10 @@ import { } from "../../api/types"; import { ContentFilterValue } from "../ContentControl"; import { DetailLevel } from "../DetailControl"; -import { ColumnBuckets, EventsByQuarterWithGroups } from "../Timeline"; +import { ColumnBuckets } from "../useColumnInformation"; -import { useTimelineSearch } from "../timelineSearchState"; +import { EventsByQuarterWithGroups } from "../findEventGroups"; +import { useTimelineSearch } from "../timeline-search/timelineSearchState"; import { Quarter } from "./Quarter"; import YearHead from "./YearHead"; diff --git a/frontend/src/js/entity-history/timeline/YearHead.tsx b/frontend/src/js/entity-history/timeline/YearHead.tsx index 70a55f7c812..fb68f710844 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 "./lib/util"; const Root = styled("div")` font-size: ${({ theme }) => theme.font.xs}; diff --git a/frontend/src/js/entity-history/timeline/util.ts b/frontend/src/js/entity-history/timeline/util.ts deleted file mode 100644 index 59d327654eb..00000000000 --- a/frontend/src/js/entity-history/timeline/util.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ColumnDescription } from "../../api/types"; - -export const isIdColumn = (columnDescription: ColumnDescription) => - columnDescription.semantics.some((s) => s.type === "ID"); - -export const isDateColumn = (columnDescription: ColumnDescription) => - columnDescription.semantics.some((s) => s.type === "EVENT_DATE"); - -export const isSourceColumn = (columnDescription: ColumnDescription) => - columnDescription.semantics.some((s) => s.type === "SOURCES"); - -export const isGroupableColumn = (columnDescription: ColumnDescription) => - columnDescription.semantics.some((s) => s.type === "GROUP"); - -export const isVisibleColumn = (columnDescription: ColumnDescription) => - columnDescription.semantics.length === 0 || - columnDescription.semantics.every((s) => s.type !== "HIDDEN"); - -export const isConceptColumn = (columnDescription: ColumnDescription) => - columnDescription.semantics.some((s) => s.type === "CONCEPT_COLUMN"); - -export const isMoneyColumn = (columnDescription: ColumnDescription) => - columnDescription.type === "MONEY"; - -export const isSecondaryIdColumn = (columnDescription: ColumnDescription) => - columnDescription.semantics.some((s) => s.type === "SECONDARY_ID"); - -export const formatCurrency = (value: number, digits?: number) => - value.toLocaleString(navigator.language, { - style: "currency", - currency: "EUR", - unitDisplay: "short", - minimumFractionDigits: digits, - maximumFractionDigits: digits, - }); 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, + }; +};