Vizzuality/landgriffon

View on GitHub
client/src/containers/analysis-visualization/analysis-table/index.tsx

Summary

Maintainability
F
4 days
Test Coverage
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DownloadIcon, InformationCircleIcon } from '@heroicons/react/outline';
import { uniq, omit } from 'lodash-es';
import toast from 'react-hot-toast';
import { ArrowLeftIcon } from '@heroicons/react/solid';
import { useSearchParams } from 'next/navigation';

import ComparisonCell from './comparison-cell/component';
import ChartCell from './chart-cell';
import AnalysisTableFooter from './footer';

import { useAppSelector, useSyncIndicators, useSyncTableDetailView } from 'store/hooks';
import { filtersForTabularAPI } from 'store/features/analysis/selector';
import { scenarios } from 'store/features/analysis/scenarios';
import { useIndicators } from 'hooks/indicators';
import {
  useImpactData,
  useDownloadImpactData,
  useDownloadImpactActualVsScenarioData,
  useDownloadImpactScenarioVsScenarioData,
} from 'hooks/impact';
import { useImpactComparison, useImpactScenarioComparison } from 'hooks/impact/comparison';
import AnalysisDynamicMetadata from 'containers/analysis-visualization/analysis-dynamic-metadata';
import { Button } from 'components/button';
import Table from 'components/table/component';
import { formatNumber } from 'utils/number-format';
import { DEFAULT_PAGE_SIZES } from 'components/table/pagination/constants';
import { handleResponseError } from 'services/api';

import type {
  ExpandedState,
  PaginationState,
  RowSelectionState,
  SortingState,
  TableState,
  Table as TableType,
} from '@tanstack/react-table';
import type { TableProps } from 'components/table/component';
import type { ColumnDefinition } from 'components/table/column';
import type { ChartData } from './chart-cell/types';
import type { ComparisonMode, ImpactRowType, ImpactTableValueItem } from './types';

const isParentRow = <Mode extends ComparisonMode>(
  row: ImpactRowType<Mode, true | false>,
): row is ImpactRowType<Mode, true> => {
  return 'metadata' in row;
};

const AnalysisTable = () => {
  const searchParams = useSearchParams();
  const isComparisonEnabled = Boolean(searchParams.get('compareScenarioId'));

  const [paginationState, setPaginationState] = useState<PaginationState>({
    pageIndex: 1,
    pageSize: DEFAULT_PAGE_SIZES[0],
  });
  const [sortingState, setSortingState] = useState<SortingState>([]);
  const [expandedState, setExpandedState] = useState<ExpandedState>(null);
  const [rowSelectionState, setRowSelectionState] = useState<RowSelectionState>({});
  const tableState: Partial<TableState> = useMemo(() => {
    return {
      pagination: paginationState,
      sorting: sortingState,
      expanded: expandedState,
      rowSelection: rowSelectionState,
    };
  }, [expandedState, paginationState, rowSelectionState, sortingState]);

  const [syncedIndicators, setSyncedIndicators] = useSyncIndicators();
  const [syncedDetailView, setSyncedDetailView] = useSyncTableDetailView();

  const selectedIndicators = syncedIndicators;

  const { scenarioToCompare, currentScenario } = useAppSelector(scenarios);
  const { data: indicators } = useIndicators(undefined, { select: (data) => data?.data });
  const downloadImpactData = useDownloadImpactData({
    onSuccess: () => {
      toast.success('Data was downloaded successfully');
    },
    onError: handleResponseError,
  });

  const downloadActualVsScenarioData = useDownloadImpactActualVsScenarioData({
    onSuccess: () => {
      toast.success('Data was downloaded successfully');
    },
    onError: handleResponseError,
  });

  const downloadScenarioVsScenarioData = useDownloadImpactScenarioVsScenarioData({
    onSuccess: () => {
      toast.success('Data was downloaded successfully');
    },
    onError: handleResponseError,
  });

  const { indicatorId, ...restFilters } = useAppSelector(filtersForTabularAPI);

  const useIsComparison = useCallback(
    (table: ImpactRowType<ComparisonMode>[]): table is ImpactRowType<true | 'scenario'>[] => {
      return isComparisonEnabled && !!scenarioToCompare;
    },
    [isComparisonEnabled, scenarioToCompare],
  );

  const useIsScenarioComparison = useCallback(
    (table: ImpactRowType<ComparisonMode>[]): table is ImpactRowType<'scenario'>[] => {
      return isComparisonEnabled && !!currentScenario;
    },
    [isComparisonEnabled, currentScenario],
  );

  const isEnable =
    !!indicators?.length &&
    !!restFilters.startYear &&
    !!restFilters.endYear &&
    restFilters.endYear !== restFilters.startYear;

  const indicatorIds = useMemo(() => {
    if (Array.isArray(selectedIndicators)) {
      return selectedIndicators.map((id) => id);
    }

    if (selectedIndicators && !Array.isArray(selectedIndicators)) {
      return [selectedIndicators];
    }

    return indicators.map((indicator) => indicator.id);
  }, [indicators, selectedIndicators]);

  const sortingParams = useMemo(() => {
    if (!!sortingState.length) {
      return {
        sortingYear: Number(sortingState?.[0].id),
        sortingOrder: sortingState[0].desc ? 'DESC' : 'ASC',
      };
    }
    return {};
  }, [sortingState]);

  const params = useMemo(
    () => ({
      indicatorIds,
      startYear: restFilters.startYear,
      endYear: restFilters.endYear,
      groupBy: restFilters.groupBy,
      ...restFilters,
      ...sortingParams,
      scenarioId: currentScenario,
      'page[number]': paginationState.pageIndex,
      'page[size]': paginationState.pageSize,
    }),
    [
      currentScenario,
      indicatorIds,
      paginationState.pageIndex,
      paginationState.pageSize,
      restFilters,
      sortingParams,
    ],
  );

  const plainImpactData = useImpactData(params, {
    enabled: !isComparisonEnabled && isEnable,
  });

  const impactActualComparisonData = useImpactComparison(
    { ...omit(params, 'scenarioId'), comparedScenarioId: scenarioToCompare },
    {
      enabled: isComparisonEnabled && !currentScenario && isEnable,
    },
  );
  const impactScenarioComparisonData = useImpactScenarioComparison(
    {
      ...omit(params, 'scenarioId'),
      baseScenarioId: currentScenario,
      comparedScenarioId: scenarioToCompare,
    },
    {
      enabled: isComparisonEnabled && !!currentScenario && isEnable,
    },
  );

  const impactComparisonData = !!currentScenario
    ? impactScenarioComparisonData
    : impactActualComparisonData;

  const {
    data: impactData,
    isLoading,
    isFetching,
  } = useMemo(() => {
    if (isComparisonEnabled && !!scenarioToCompare) return impactComparisonData;
    return plainImpactData;
  }, [impactComparisonData, plainImpactData, isComparisonEnabled, scenarioToCompare]);

  const {
    data: { impactTable = [] },
    metadata,
  } = useMemo(() => {
    if (impactData) return impactData;
    return { data: { impactTable: [] }, metadata: {} };
  }, [impactData]);

  const firstProjectedYear = useMemo(() => {
    if (!impactTable) return null;
    return impactTable[0]?.rows[0]?.values.find((value) => value.isProjected)?.year;
  }, [impactTable]);

  const handleDownloadData = useCallback(async () => {
    let csv = null;
    // actual vs scenario
    if (!currentScenario && scenarioToCompare) {
      csv = await downloadActualVsScenarioData.mutateAsync({
        ...omit(params, 'page[number]', 'page[size]'),
        comparedScenarioId: scenarioToCompare,
      });
    }
    // scenario vs scenario
    else if (currentScenario && scenarioToCompare) {
      csv = await downloadScenarioVsScenarioData.mutateAsync({
        ...omit(params, 'page[number]', 'page[size]', 'scenarioId'),
        baseScenarioId: currentScenario,
        comparedScenarioId: scenarioToCompare,
      });
    }
    // no scenario or comparison
    else {
      csv = await downloadImpactData.mutateAsync(omit(params, 'page[number]', 'page[size]'));
    }

    if (csv) {
      const url = window.URL.createObjectURL(new Blob([csv]));
      const link = document.createElement('a');
      link.setAttribute('href', url);
      link.setAttribute('download', `impact_data_${Date.now()}.csv`);
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
    }
    // do not pass pagination params to download data endpoint
  }, [
    currentScenario,
    downloadActualVsScenarioData,
    downloadImpactData,
    downloadScenarioVsScenarioData,
    params,
    scenarioToCompare,
  ]);

  const handleExpandedChange = useCallback(
    <Mode extends ComparisonMode>(table: TableType<ImpactRowType<Mode>>) => {
      if (!!expandedState) {
        const expandedIds = Object.keys(expandedState);
        const rowsToSelect = {};
        table
          .getRowModel()
          .rows.filter((row) => expandedIds.includes(row.id))
          .forEach((row) => {
            rowsToSelect[row.id] = true;
            row.originalSubRows.forEach(
              (_subRow, index) => (rowsToSelect[`${row.id}.${index}`] = true),
            );
          });
        setRowSelectionState(rowsToSelect);
      }
    },
    [expandedState],
  );

  // Years from impact table
  const years = useMemo(() => {
    // TODO: do we have to check all rows or is the first one guaranteed to have all years?
    // const years = impactTable[0]?.yearSum?.map((sum) => sum.year);
    const years = impactTable?.flatMap(({ yearSum }) => yearSum.map((sum) => sum.year));

    // TODO: if the above is true, we don't need this
    return uniq(years);
  }, [impactTable]);

  const initialTableData: ImpactRowType<ComparisonMode>[] = useMemo(
    () =>
      impactTable.map(({ indicatorShortName, yearSum, rows, ...impact }) => ({
        ...impact,
        children: rows,
        name: indicatorShortName,
        ...(yearSum && {
          values: yearSum.map((sum) => ({
            ...sum,
            isProjected: rows[0]?.values.find((v) => v.year === sum.year)?.isProjected,
          })),
        }),
      })),
    [impactTable],
  );

  const [tableData, setTableData] = useState<ImpactRowType<ComparisonMode>[]>([]);

  useEffect(() => {
    // ? a single indicator is expanded
    if (syncedDetailView) {
      setTableData(initialTableData.find((row) => row.indicatorId === syncedDetailView)?.children);
    }

    //  ? several indicators are selected
    if (selectedIndicators && !syncedDetailView) {
      setTableData(initialTableData.filter((row) => selectedIndicators.includes(row.indicatorId)));
    }

    // ? all indicators are selected
    if (!selectedIndicators?.length && !syncedDetailView) {
      setTableData(initialTableData);
    }

    setExpandedState(null);
    setRowSelectionState({});
  }, [selectedIndicators, initialTableData, syncedDetailView]);

  const handleExitExpanded = useCallback(() => {
    setExpandedState({});
    setSyncedDetailView(null);

    if (syncedIndicators?.length === 1) {
      setSyncedIndicators(null);
    }
  }, [setSyncedDetailView, syncedIndicators, setSyncedIndicators]);

  const handleExpandRow = useCallback(
    (indicatorId: string) => {
      setExpandedState({});
      setSyncedDetailView(indicatorId);
    },
    [setSyncedDetailView],
  );

  const isComparison = useIsComparison(tableData);
  const isScenarioComparison = useIsScenarioComparison(tableData);

  const valueIsScenarioComparison = useCallback(
    (value: ImpactTableValueItem<ComparisonMode>): value is ImpactTableValueItem<'scenario'> => {
      return isScenarioComparison && isComparison;
    },
    [isComparison, isScenarioComparison],
  );

  const expanded = useMemo(() => {
    return indicators.find((i) => i.id === syncedDetailView);
  }, [syncedDetailView, indicators]);

  const comparisonColumn = useCallback(
    <Mode extends ComparisonMode>(year: number): ColumnDefinition<ImpactRowType<Mode>> => {
      const valueIsComparison = (
        value: ImpactTableValueItem<ComparisonMode>,
      ): value is ImpactTableValueItem<true> => {
        return !isScenarioComparison && isComparison;
      };

      return {
        header: () => <span className="text-gray-900">{year}</span>,
        id: `${year}`,
        size: 170,
        align: 'center',
        enableSorting: true,
        cell: ({ row: { original: data, id }, table }) => {
          //* The metadata is only present at the parent row, so we need to get it from there
          const { rowsById } = table.getExpandedRowModel();
          const parentRowData = rowsById[id.split('.')[0]].original as unknown as ImpactRowType<
            Mode,
            true
          >;

          const unit: string = parentRowData.metadata?.unit || expanded?.metadata?.units;

          const value = data.values?.find((value) => value.year === year);
          const isComparison = valueIsComparison(value);
          const isScenarioComparison = valueIsScenarioComparison(value);

          if (!isComparison && !isScenarioComparison) {
            if (unit) {
              return `${formatNumber(value.value)} ${unit}`;
            }
            return formatNumber(value?.value);
          }

          if (isScenarioComparison) {
            const { baseScenarioValue, comparedScenarioValue, ...rest } = value;
            return (
              <ComparisonCell
                {...rest}
                value={baseScenarioValue}
                scenarioValue={comparedScenarioValue}
                unit={unit}
                formatter={formatNumber}
                isFirs
              />
            );
          }

          return (
            <ComparisonCell
              {...value}
              scenarioValue={value.comparedScenarioValue}
              formatter={formatNumber}
            />
          );
        },
      };
    },
    [expanded, isComparison, isScenarioComparison, valueIsScenarioComparison],
  );

  const baseColumns = useMemo(
    <Mode extends ComparisonMode>(): ColumnDefinition<ImpactRowType<Mode>>[] => [
      {
        id: 'name',
        header: () => (
          <div>
            {!!expanded?.name ? (
              <Button
                className="border-0 bg-transparent pb-1 pl-0 pr-0 pt-1"
                variant="transparent"
                onClick={handleExitExpanded}
              >
                <div className="flex max-w-[200px] whitespace-normal text-start text-sm font-semibold text-gray-900">
                  <ArrowLeftIcon className="mr-3.5 h-4 w-4" />
                  {expanded.name}
                </div>
              </Button>
            ) : (
              <span className="block py-[0.70rem] text-sm normal-case text-gray-400">
                Selected Indicators
              </span>
            )}
          </div>
        ),
        align: 'left',
        isSticky: 'left',
        size: 260,
        cell: ({ row: { original, depth } }) => {
          const name =
            isParentRow(original) &&
            depth === 0 &&
            indicators.find((i) => i.id === original.indicatorId)?.metadata?.short_name;

          return (
            <div className="flex gap-4 py-5">
              {!expanded?.name && (
                <InformationCircleIcon className="h-4 w-4 shrink-0 text-gray-900" />
              )}
              <div>
                {expanded?.name ? (
                  original.name
                ) : (
                  <div className="block font-semibold">
                    {name || original.name}
                    {isParentRow(original) && depth === 0 && <> ({original.metadata.unit})</>}
                  </div>
                )}

                {!expanded?.name && isParentRow(original) && (
                  <Button
                    variant="white"
                    className="mt-4"
                    onClick={() => handleExpandRow(original.indicatorId)}
                  >
                    View detail
                  </Button>
                )}
              </div>
            </div>
          );
        },
      },
      {
        id: 'datesRangeChart',
        header: () => (
          <span className="text-gray-900">
            {years?.length ? `${years[0]}-${years[years.length - 1]}` : '-'}
          </span>
        ),
        className: 'px-2 mx-auto',
        align: 'center',
        size: 170,
        cell: ({
          row: {
            original: { values },
          },
        }) => {
          const chartData = values as ChartData[];
          return (
            <div className="mx-auto my-3 h-5 w-[130px]">
              <ChartCell data={chartData} />
            </div>
          );
        },
      },
      ...years.map((year) => comparisonColumn<Mode>(year as number)),
    ],
    [years, expanded?.name, handleExitExpanded, indicators, handleExpandRow, comparisonColumn],
  );

  const tableProps = useMemo(
    <Mode extends ComparisonMode>(): TableProps<ImpactRowType<Mode>> & {
      firstProjectedYear: number;
    } => ({
      showPagination: Boolean(syncedDetailView),
      paginationProps: {
        totalItems: metadata.totalItems,
        totalPages: metadata.totalPages,
        currentPage: metadata.page,
        pageSize: metadata.size,
      },
      getSubRows: (row) => row.children,
      state: tableState,
      onSortingChange: setSortingState,
      onPaginationChange: setPaginationState,
      onExpandedChange: setExpandedState,
      isLoading: isFetching,
      enableExpanding: Boolean(syncedDetailView),
      data: (tableData as ImpactRowType<Mode>[]) || [],
      columns: baseColumns as ColumnDefinition<ImpactRowType<Mode>>[],
      handleExpandedChange,
      firstProjectedYear,
    }),
    [
      metadata,
      tableState,
      isFetching,
      tableData,
      baseColumns,
      handleExpandedChange,
      firstProjectedYear,
      syncedDetailView,
    ],
  );

  return (
    <div className="flex flex-1 flex-col space-y-6 px-6 pb-6">
      <div className="flex justify-between">
        <div className="flex w-full items-end justify-between">
          <AnalysisDynamicMetadata />
          <div>
            <Button
              variant="secondary"
              size="base"
              className="flex-shrink-0"
              disabled={
                isLoading ||
                downloadImpactData.isLoading ||
                downloadActualVsScenarioData.isLoading ||
                downloadScenarioVsScenarioData.isLoading
              }
              icon={<DownloadIcon />}
              onClick={handleDownloadData}
              loading={
                downloadImpactData.isLoading ||
                downloadActualVsScenarioData.isLoading ||
                downloadScenarioVsScenarioData.isLoading
              }
            >
              Download Data
            </Button>
          </div>
        </div>
      </div>
      <div className="relative flex-1" data-testid="analysis-table">
        <Table {...tableProps} />
      </div>
      {isComparisonEnabled && <AnalysisTableFooter />}
    </div>
  );
};

export default AnalysisTable;