hyper-tuner/hyper-tuner-cloud

View on GitHub
src/components/Tune/Dialog.tsx

Summary

Maintainability
D
1 day
Test Coverage
import { QuestionCircleOutlined } from '@ant-design/icons';
import {
  Config as ConfigType,
  ConstantTypes,
  Curve as CurveType,
  Dialog as DialogType,
  Dialogs as DialogsType,
  Field as FieldType,
  ScalarConstant as ScalarConstantType,
  Table as TableType,
  Tune as TuneType,
} from '@hyper-tuner/types';
import { Col, Divider, Form, Popover, Result, Row, Space } from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { connect } from 'react-redux';
import useBrowserStorage from '../../hooks/useBrowserStorage';
import useConfig from '../../hooks/useConfig';
import { AppState, UIState } from '../../types/state';
import { evaluateExpression } from '../../utils/tune/expression';
import { parseXy, parseZ } from '../../utils/tune/table';
import Loader from '../Loader';
import Curve from './Dialog/Curve';
import Map3D from './Dialog/Map3D';
import SmartNumber from './Dialog/SmartNumber';
import SmartSelect from './Dialog/SmartSelect';
import TextField from './Dialog/TextField';

type DialogsAndCurves = Record<string, DialogType | CurveType | TableType>;

interface RenderedPanel {
  type: string;
  name: string;
  title: string;
  labels: string[];
  xAxis: number[];
  yAxis: number[];
  xBins: string[];
  yBins: string[];
  size: number[];
  gauge?: string;
  fields: FieldType[];
  map: string;
  page: number;
  help?: string;
  xyLabels: string[];
  zBins: string[];
  gridHeight: number;
  gridOrient: number[];
  upDownLabel: string[];
}

enum PanelTypes {
  FIELDS = 'fields',
  CURVE = 'curve',
  TABLE = 'table',
}

const mapStateToProps = (state: AppState) => ({
  config: state.config,
  tune: state.tune,
  ui: state.ui,
});

// TODO: refactor this
const Dialog = ({
  ui,
  config,
  tune,
  url,
  name,
}: {
  ui: UIState;
  config: ConfigType | null;
  tune: TuneType | null;
  name: string;
  url: string;
}) => {
  const isDataReady =
    tune && config && Object.keys(tune.constants).length && Object.keys(config.constants).length;
  const { storageSet } = useBrowserStorage();
  const { findConstantOnPage } = useConfig(config);
  const [panelsComponents, setPanelsComponents] = useState<(React.JSX.Element | null)[]>([]);
  const containerRef = useRef<HTMLDivElement | null>(null);

  const renderHelp = (link?: string) =>
    link && (
      <Popover
        content={
          <a href={link} target="__blank" rel="noopener noreferrer">
            {link}
          </a>
        }
        placement="right"
      >
        <QuestionCircleOutlined />
      </Popover>
    );

  const renderCurve = useCallback(
    (curve: CurveType) => {
      const x = tune!.constants[curve.xBins[0]];
      const y = tune!.constants[curve.yBins[0]];
      const xConstant = findConstantOnPage(curve.xBins[0]) as ScalarConstantType;
      const yConstant = findConstantOnPage(curve.yBins[0]) as ScalarConstantType;

      return (
        <Curve
          key={curve.yBins[0]}
          // disabled={false} // TODO: evaluate condition
          help={config!.help[curve.yBins[0]]}
          xLabel={curve.labels[0]}
          yLabel={curve.labels[1]}
          xUnits={xConstant.units}
          yUnits={yConstant.units}
          xData={parseXy(x.value as string)}
          yData={parseXy(y.value as string)}
        />
      );
    },
    [config?.help, findConstantOnPage, tune?.constants],
  );

  const renderTable = useCallback(
    (table: TableType | RenderedPanel) => {
      const x = tune?.constants[table.xBins[0]];
      const y = tune?.constants[table.yBins[0]];
      const z = tune?.constants[table.zBins[0]];

      if (!(x && y)) {
        // TODO: handle this (rusEFI: fuel/lambdaTableTbl)
        return null;
      }

      const zConstant = findConstantOnPage(table.zBins[0]) as ScalarConstantType;

      return (
        <div>
          {renderHelp(table.help)}
          <Map3D
            key={table.map}
            xData={parseXy(x.value as string)}
            yData={parseXy(y.value as string).reverse()}
            zData={parseZ(z?.value as string)}
            xUnits={x.units!}
            yUnits={y.units!}
            zDigits={zConstant.digits}
          />
        </div>
      );
    },
    [findConstantOnPage, tune?.constants],
  );

  const resolvedDialogs: DialogsAndCurves = {};

  const resolveDialogs = (source: DialogsType, dialogName: string) => {
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
    if (!source[dialogName]) {
      return;
    }

    // resolve root dialog
    resolvedDialogs[dialogName] = source[dialogName];

    Object.keys(source[dialogName].panels).forEach((panelName: string) => {
      const currentDialog = source[panelName];

      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
      if (!currentDialog) {
        // resolve 2D map / curve panel
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        if (config!.curves[panelName]) {
          resolvedDialogs[panelName] = {
            ...config!.curves[panelName],
          };

          return;
        }

        // resolve 3D map / table panel
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        if (config!.tables[panelName]) {
          resolvedDialogs[panelName] = {
            ...config!.tables[panelName],
          };

          return;
        }

        console.warn('Unable to resolve panel:', panelName);

        return;
      }

      if (currentDialog.fields.length > 0) {
        // resolve in root scope
        resolvedDialogs[panelName] = config!.dialogs[panelName];
      }

      // recursion
      resolveDialogs(config!.dialogs, panelName);
    });
  };

  if (config?.dialogs) {
    resolveDialogs(config.dialogs, name);
  }

  // remove dummy dialogs and flatten to array
  const panels = Object.keys(resolvedDialogs).map((dialogName: string): RenderedPanel => {
    const currentDialog: DialogType | CurveType | TableType = resolvedDialogs[dialogName];
    let type = PanelTypes.CURVE;
    let fields: FieldType[] = [];

    if ('fields' in currentDialog) {
      type = PanelTypes.FIELDS;
      fields = currentDialog.fields.filter((field) => field.title !== '');
    } else if ('zBins' in currentDialog) {
      type = PanelTypes.TABLE;
    }

    return {
      type,
      name: dialogName,
      title: currentDialog.title,
      fields,
      labels: (currentDialog as CurveType).labels,
      xAxis: (currentDialog as CurveType).xAxis,
      yAxis: (currentDialog as CurveType).yAxis,
      xBins: (currentDialog as CurveType).xBins,
      yBins: (currentDialog as CurveType).yBins,
      size: (currentDialog as CurveType).size,
      gauge: (currentDialog as CurveType).gauge,
      map: (currentDialog as TableType).map,
      page: (currentDialog as TableType).page,
      help: (currentDialog as TableType).help,
      xyLabels: (currentDialog as TableType).xyLabels,
      zBins: (currentDialog as TableType).zBins,
      gridHeight: (currentDialog as TableType).gridHeight,
      gridOrient: (currentDialog as TableType).gridOrient,
      upDownLabel: (currentDialog as TableType).upDownLabel,
    };
  });

  const generatePanelsComponents = useCallback(
    () =>
      panels.map((panel: RenderedPanel) => {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
        if (panel.type === PanelTypes.FIELDS && panel.fields.length === 0) {
          return null;
        }

        return (
          <Col key={panel.name} span={24}>
            <Divider>{panel.title}</Divider>
            {panel.fields.map((field: FieldType) => {
              const constant = findConstantOnPage(field.name);
              const tuneField = tune!.constants[field.name];
              const help = config!.help[field.name];
              let input;
              let enabled = true;
              const fieldKey = `${panel.name}-${field.title}`;

              if (field.condition) {
                // TODO: optimize it
                enabled = evaluateExpression(field.condition, tune!.constants, config!) as boolean;
              }

              if (field.name === '_fieldText_' && enabled) {
                return <TextField key={fieldKey} title={field.title} />;
              }

              // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
              if (!tuneField) {
                // TODO: handle this?
                // name: "rpmwarn", title: "Warning",
                return null;
              }

              switch (constant.type) {
                // case ConstantTypes.ARRAY: // TODO: arrays
                case ConstantTypes.BITS: {
                  input = (
                    <SmartSelect
                      defaultValue={`${tuneField.value}`}
                      values={constant.values}
                      disabled={!enabled}
                    />
                  );
                  break;
                }

                case ConstantTypes.SCALAR: {
                  input = (
                    <SmartNumber
                      defaultValue={Number(tuneField.value)}
                      digits={(constant as ScalarConstantType).digits}
                      min={((constant as ScalarConstantType).min as number) || 0}
                      max={(constant as ScalarConstantType).max as number}
                      disabled={!enabled}
                      units={(constant as ScalarConstantType).units}
                    />
                  );
                  break;
                }

                default:
                  break;
              }

              return (
                <Form.Item
                  key={fieldKey}
                  label={
                    <Space>
                      {field.title}
                      {help && (
                        <Popover
                          content={help.split('\\n').map((line) => (
                            <div key={line}>{line}</div>
                          ))}
                        >
                          <QuestionCircleOutlined />
                        </Popover>
                      )}
                    </Space>
                  }
                >
                  {input}
                </Form.Item>
              );
            })}

            {panel.type === (PanelTypes.CURVE as string) && renderCurve(panel)}
            {panel.type === (PanelTypes.TABLE as string) && renderTable(panel)}
          </Col>
        );
      }),
    [config, findConstantOnPage, panels, renderCurve, renderTable, tune?.constants],
  );

  useEffect(() => {
    storageSet('lastDialog', url);

    if (isDataReady) {
      setPanelsComponents(generatePanelsComponents());
    }
  }, [isDataReady, url, ui.sidebarCollapsed]);

  if (!isDataReady) {
    return <Loader />;
  }

  const dialogConfig = config.dialogs[name];
  const curveConfig = config.curves[name];
  const tableConfig = config.tables[name];

  // standalone dialog / page
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  if (!dialogConfig) {
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
    if (curveConfig) {
      return (
        <div ref={containerRef} className="large-container">
          <Divider>{curveConfig.title}</Divider>
          {renderCurve(curveConfig)}
        </div>
      );
    }

    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
    if (tableConfig) {
      return (
        <div ref={containerRef} className="large-container">
          {renderHelp(tableConfig.help)}
          <Divider>{tableConfig.title}</Divider>
          {renderTable(tableConfig)}
        </div>
      );
    }

    return <Result status="warning" title="Dialog not found" style={{ marginTop: 50 }} />;
  }

  return (
    <div ref={containerRef} className="large-container">
      {renderHelp(dialogConfig.help)}
      <Form labelCol={{ span: 10 }} wrapperCol={{ span: 10 }}>
        <Row gutter={20}>{panelsComponents}</Row>
      </Form>
    </div>
  );
};

export default connect(mapStateToProps)(Dialog);