• Operand
  • da ploy? ed.

gram: card

> ./src/ui/WcPanel.tsx

Lenses
(coming soon!)


import React, { useRef, useState } from "react";
import { HotTable } from '@handsontable/react';
import "handsontable/dist/handsontable.full.css";
import "./overrides.css";
import styled from 'styled-components'
import { Record, Attribute } from '../core/types'
import Handsontable from "handsontable";
import { FormulaEditor } from '../ui/cell_editors/formulaEditor';
import mapValues from 'lodash/mapValues'
import AutosuggestInput from './AutosuggestInput'
import { getCreatingAdapter, setCreatingAdapter } from "../end_user_scraper/state";

const marketplaceUrl = "https://wildcard-marketplace.herokuapp.com";

function formatRecordsForHot(records:Array<Record>) {
  return records.map(record => ({
    id: record.id,
    ...mapValues(record.values, v => v instanceof HTMLElement ? v.textContent : v)
  }))
}

function formatAttributesForHot(attributes:Array<Attribute>) {
  return attributes.map(attribute => ({
    data: attribute.name,

    // If it's an "element" attribute, just render it as text
    type: attribute.type === "element" ? "text" : attribute.type,
    readOnly: !attribute.editable,
    editor: attribute.editor
  }))
}

const ToggleButton = styled.div`
  display: block;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  font-size: 14px;
  border-radius: 10px;
  z-index: 10000;
  padding: 10px;
  position: fixed;
  bottom: ${props => props.hidden ? 20 : 300}px;
  right: ${props => props.codeEditorHidden ? 2 : 31}vw;
  background-color: white;
  box-shadow: 0px 0px 10px -1px #d5d5d5;
  border: none;
  cursor: pointer;
  &:hover {
    background-color: #eee;
  }
`

const Panel = styled.div`
  position: fixed;
  bottom: 0;
  left: 0;
  height: ${props => props.hidden ? 0 : 280}px;
  width: ${props => props.codeEditorHidden ? 98 : 68.5}vw;
  z-index: 2200;
  box-shadow: 0px -5px 10px 1px rgba(170,170,170,0.5);
  border-top: solid thin #9d9d9d;
  overflow: hidden;
  background-color: white;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  font-size: 14px;
`

const ControlBar = styled.div`
  height: 30px;
  padding: 5px 10px;
`

const EditorButton = styled(ToggleButton)`
  display: ${props => props.codeEditorHidden ? 'none' : 'block'};
  bottom: 20px;
  right: ${props => props.right};
`

const ShareButton = styled(ToggleButton)`
  /* right: ${props => props.right}; */
  right: calc(2vw + 165px);
  display: ${props => props.hidden || !props.codeEditorHidden ? 'none' : 'block'};
`

const EditButton = styled(ToggleButton)`
  /* right: ${props => props.right}; */
  right: calc(2vw + 180px);
  display: ${props => props.hidden || !props.codeEditorHidden ? 'none' : 'block'};
`

// Declare our functional React component

const WcPanel = ({ records = [], attributes, query, actions, adapter }) => {
  const creatingAdapter = getCreatingAdapter();
  const hotRef = useRef(null);
  const cellEditorRef = useRef(null);
  const [hidden, setHidden] = useState(false);
  // Declare a new state variable for adapter code
  const [adapterCode, setAdapterCode] = useState("");
  const [codeEditorHidden, setCodeEditorHidden] = useState(true);
  const _adapterKey = "localStorageAdapter:adapters:" + adapter.name;


  // Keep track of the currently selected cell
  const [activeCell, setActiveCell] = useState(null)

  // The value of the selected cell.
  // (Including in-progress updates that we are making in the UI)
  const [activeCellValue, setActiveCellValue] = useState('')

  // Autosuggest suggestions
  const [suggestions, setSuggestions] = useState([]);

  const onCellEditorKeyPress = (e) => {
    const key = e.key
    if (key === 'Enter') {
      cellEditorRef.current.blur()
    }
  }

  const commitActiveCellValue = () => {
    if(activeCellValue[0] === "=") {
      actions.setFormula(
        activeCell.attribute.tableId,
        activeCell.attribute.name,
        activeCellValue
      )
    } else {
      actions.editRecords([
        {
          tableId: activeCell.attribute.tableId,
          recordId: activeCell.record.id,
          attribute: activeCell.attribute.name,
          value: activeCellValue
        }
      ])
    }
  }

  const hotSettings = {
    data: formatRecordsForHot(records),
    rowHeaders: true,
    columns: formatAttributesForHot(attributes),
    colHeaders: attributes.map(attr => {
      if(attr.formula) {
        return `<span class="formula-header">${attr.name}</span>`
      } else if(attr.type === "element") {
        return `<span class="element-header">${attr.name}</span>`
      } else {
        return `<span class="data-header">${attr.name}</span>`
      }
    }),
    columnSorting: true,

    // Set a low column width,
    // then let HOT stretch out the columns to match the width
    width: "100%",
    colWidths: attributes.map(a => 100),
    stretchH: "all" as const,
    wordWrap: false,
    manualColumnResize: true,

    // todo: parameterize height, make whole panel stretchable
    height: 250,

    cells: (row, col, prop) => {
      const cellProperties:any = {}
      const attr = attributes.find(a => a.name === prop)
      if (attr.formula) {
        cellProperties.formula = attr.formula
        cellProperties.editor = FormulaEditor
        cellProperties.placeholder = "loading..."
      }
      return cellProperties
    },

    hiddenColumns: {
      columns: attributes.map((attr, idx) => attr.hidden ? idx : null).filter(e => Number.isInteger(e))
    },
    // contextMenu: {
    //   items: {
    //     "insert_user_attribute": {
    //       name: 'Insert User Column',
    //       callback: function(key, selection, clickEvent) {
    //         // TODO: For now, new columns always get added to the user table.
    //         // Eventually, do we want to allow adding to the main site table?
    //         // Perhaps that'd be a way of extending scrapers using formulas...
    //         actions.addAttribute("user");
    //       }
    //     },
    //     "rename_user_attribute": {
    //       // todo: disable this on site columns
    //       name: 'Rename column',
    //       callback: function(key, selection, clickEvent) {
    //         alert('not implemented yet');
    //       }
    //     },
    //     "clear_user_table": {
    //       name: 'Clear user columns',
    //       callback: function(key, selection, clickEvent) {
    //         // TODO: For now, new columns always get added to the user table.
    //         // Eventually, do we want to allow adding to the main site table?
    //         // Perhaps that'd be a way of extending scrapers using formulas...
    //         actions.clear("user");
    //       }
    //     },
    //     "toggle_column_visibility":{
    //       name: 'Show/hide column in page',
    //       disabled: () => {
    //         // only allow toggling visibility on user table
    //         const colIndex = getHotInstance().getSelectedLast()[1]
    //         const attribute = attributes[colIndex]

    //         return attribute.tableId !== "user"
    //       },
    //       callback: function(key, selection, clickEvent) {
    //         const attribute = attributes[selection[0].start.col];

    //         // NOTE! idx assumes that id is hidden.
    //         actions.toggleVisibility(attribute.tableId, attribute.name);
    //       }
    //     },
    //   }
    // }
  }

  // Get a pointer to the current handsontable instance
  const getHotInstance = () => {
    if (hotRef && hotRef.current) { return hotRef.current.hotInstance; }
    else { return null; }
  }

  // make sure the HOT reflects the current sort config
  // of the query in our redux state.
  // (usually the sort config will be set from within HOT,
  // but this is needed e.g. to tell HOT when we load sort state from
  // local storage on initial pageload)
  const updateHotSortConfig = () => {
    if (getHotInstance()) {
      const columnSortPlugin = getHotInstance().getPlugin('columnSorting');

      let newHotSortConfig;

      if (query.sortConfig) {
        newHotSortConfig = {
          column: attributes.map(a => a.name).indexOf(query.sortConfig.attribute),
          sortOrder: query.sortConfig.direction
        };
      } else {
        newHotSortConfig = undefined;
      }
      columnSortPlugin.setSortConfig(newHotSortConfig);
    }
  }

  // todo: don't define these handlers inside the render funciton?
  // define outside and parameterize on props?

  // Handle user sorting the table
  const onBeforeColumnSort = (_, destinationSortConfigs) => {
    const columnSortPlugin = getHotInstance().getPlugin('columnSorting');
    // We suppress HOT's built-in sorting by returning false,
    // and manually tell HOT that we've taken care of
    // sorting the table ourselves.
    // https://handsontable.com/docs/7.4.2/demo-sorting.html#custom-sort-implementation
    columnSortPlugin.setSortConfig(destinationSortConfigs);

    // for the moment we only support single column sort
    const sortConfig = destinationSortConfigs[0];

    if (sortConfig) {
      actions.sortRecords({
        // Sort config gives us a numerical index; convert to attribute
        attribute: attributes[sortConfig.column].name,
        direction: sortConfig.sortOrder
      });
    } else {
      actions.sortRecords(null);
    }

    // don't let HOT sort the table
    return false;
  }

  // Handle user making a change to the table.
  // Similar to sorting, we suppress HOT built-in behavior, and
  // we handle the edit ourselves by triggering an action and
  // eventually rendering a totally fresh table from scratch
  const onBeforeChange = (changes, source) => {
    const edits = changes.map(([rowIndex, propName, prevValue, nextValue]) => {
      const attribute = attributes.find(a => a.name === propName)

      return {
        tableId: attribute.tableId,
        recordId: records[rowIndex].id,
        attribute: attribute.name,
        value: nextValue
      }
    })

    const dataEdits = edits.filter(e => e.value[0] !== "=")
    const formulaEdits = edits.filter(e => e.value[0] === "=")

    console.log({dataEdits, formulaEdits})

    actions.editRecords(dataEdits);

    for (const formulaEdit of formulaEdits) {
      actions.setFormula(
        formulaEdit.tableId,
        formulaEdit.attribute,
        formulaEdit.value
      )
    }

    // don't let HOT edit the value
    return false;
  }

  const onAfterSelection = (rowIndex, prop) => {
    const record = records[rowIndex]
    const attribute = attributes.find(attr => attr.name === prop)
    
    actions.selectRecord(record.id, prop)
    
    setActiveCell({ record, attribute })

    let activeCellValue

    if (attribute.formula) {
      activeCellValue = attribute.formula
    } else if (attribute.type === "element") {
      activeCellValue = record.values[attribute.name].outerHTML
    } else {
      activeCellValue = record.values[attribute.name] || ""
    }
    setActiveCellValue(activeCellValue)
  }


  const loadAdapterCode = function () {
    let loaded = false;
    // setup listener
    chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
      switch (request.command) {
        case 'openCodeEditor':
          // show code editor
          setCodeEditorHidden(false);
          sendResponse({ codeEditorHidden: false });

          // load adapter code
          if (!loaded) {
            loaded = true;
            chrome.storage.local.get(_adapterKey, (results) => {
              setAdapterCode(results[_adapterKey]);
              console.log("loaded code from storage");
            });
          }
          break;
        default:
          break;
      }
    });
  }

  const onBlurCodeEditor = function(e, code){
    const data = code.getValue();
    console.log('Editor Data: ',data);
    setAdapterCode(data);
  }

  const saveAdapterCode = function() {
    chrome.storage.local.set({ [_adapterKey]: adapterCode }, function() {
    console.log("saved changes");
    });
  }
  return <>
    {!creatingAdapter && (
      <>
        <EditButton hidden={hidden} codeEditorHidden={codeEditorHidden}
          onClick={() => {
            setCreatingAdapter(true);
            chrome.runtime.sendMessage({ command: 'editAdapter' });
        }}> Edit Wildcard Table
        </EditButton>
        <ToggleButton hidden={hidden} onClick={ () => setHidden(!hidden)}
        codeEditorHidden={codeEditorHidden}>
          { hidden ? "↑ Open Wildcard Table" : "↓ Close Wildcard Table" }
        </ToggleButton>
      </>
    )}
    <Panel hidden={hidden} codeEditorHidden={codeEditorHidden}>
      <ControlBar>
        <strong>Wildcard v0.2</strong>
      <AutosuggestInput
        activeCellValue={activeCellValue}
        setActiveCellValue={setActiveCellValue}
        suggestions={suggestions}
        setSuggestions={setSuggestions}
        cellEditorRef={cellEditorRef}
        attributes={attributes}
        onCellEditorKeyPress={onCellEditorKeyPress}
        commitActiveCellValue={commitActiveCellValue}
      />
      </ControlBar>
      <HotTable
        licenseKey='non-commercial-and-evaluation'
        beforeColumnSort={onBeforeColumnSort}
        beforeChange={onBeforeChange}
        afterSelectionByProp={onAfterSelection}
        afterRender={updateHotSortConfig}
        settings = {hotSettings}
        ref={hotRef} />
    </Panel>
    <EditorButton codeEditorHidden={codeEditorHidden} right="70px"
      onClick={() => {saveAdapterCode();}}> Save
    </EditorButton>
    <EditorButton codeEditorHidden={codeEditorHidden} right="10px"
      onClick={() => setCodeEditorHidden(true)}> Close
    </EditorButton>
  </>;
}

export default WcPanel;