ChemPal Documentation - v1.7.0
    Preparing search index...
    • Defines the column configuration for the product results table. Each column declares its accessor, cell renderer, sort/filter functions, and (optionally) meta.drawer to opt into a pre-search drawer accordion.

      The returned order is the render order in both the table header and the drawer's column-backed sections (see DrawerSearchPanel).

      Returns ColumnDef<Product, unknown>[]

      Ordered TanStack column definitions for the Product row type.

      const columns = TableColumns();
      useReactTable({ columns, data, ... });
      // columns.map(c => c.id) →
      // ["expander", "title", "supplier", "country", "shipping",
      // "availability", "description", "price", "quantity", "uom",
      // "unitPrice", "priceTrend", "sds", "specs", "coa", "cas", "pubchem", "formula",
      // "moleweight", "purity", "concentration"]
      // columns.filter(c => c.meta?.drawer).map(c => c.id) →
      // ["supplier", "country", "shipping", "availability", "price"]
      export default function TableColumns(): ColumnDef<Product, unknown>[] {
      return [
      {
      id: 'expander',
      header: () => null,
      cell: ({ row }: ProductRow) => {
      // `getCanExpand` (backed by hasExpandableDetail) already gates whether
      // there's anything to show in the panel — image, variants, or details.
      return row.getCanExpand() ? (
      <button
      {...{
      onClick: row.getToggleExpandedHandler(),
      style: { cursor: 'pointer' },
      }}
      className={styles['svg-button-icon']}
      >
      {row.getIsExpanded() ? (
      <ArrowDropDownIcon fontSize="small" />
      ) : (
      <ArrowRightIcon fontSize="small" />
      )}
      </button>
      ) : null;
      },
      enableHiding: false,
      // Fixed width sized to fit the 20px toggle button (.svg-button-icon) so
      // the expand/collapse chevron isn't clipped under table-layout: fixed.
      minSize: 24,
      maxSize: 24,
      size: 24,
      enableSorting: false,
      enableColumnFilter: false,
      enableResizing: false,
      },
      {
      id: 'title',
      accessorKey: 'title',
      header: i18n('column_title'),
      cell: ({ row }: ProductRow) => {
      // Indent variant rows so the hierarchy is visually obvious — 16px per
      // depth level, matching the chevron column width.
      const indent = row.depth > 0 ? row.depth * 16 : 0;
      return (
      <span style={{ paddingLeft: indent, display: 'inline-block' }}>
      <Link
      history={{
      type: 'product',
      data: omit(row.original, 'variants'),
      }}
      href={row.original.permalink ?? row.original.url}
      >
      {row.original.title}
      </Link>
      </span>
      );
      },
      enableHiding: false,
      filterFn: 'includeHierarchy',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_title'),
      filterVariant: 'text',
      // Titles are long; let the column grow wider than most (to this cap),
      // then word-wrap within that width.
      autoSizeMax: 240,
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'supplier',
      header: i18n('column_supplier'),
      accessorKey: 'supplier',
      cell: (info) => info.getValue(),
      filterFn: 'multiSelect',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_supplier'),
      filterVariant: 'select',
      style: {
      textAlign: 'left',
      },
      drawer: {
      label: i18n('drawer_supplier_label'),
      widget: 'autocompleteStrings',
      options: SupplierFactory.supplierList(),
      optionLabels: SupplierFactory.supplierDisplayNames(),
      emptyHelperText: i18n('drawer_supplier_empty_helper'),
      placeholder: i18n('drawer_supplier_placeholder'),
      bind: { kind: 'selectedSuppliers' },
      },
      },
      },
      {
      id: 'country',
      header: i18n('column_country'),
      accessorKey: 'supplierCountry',
      cell: ({ row }: ProductRow) => {
      const country = row.original.supplierCountry;
      if (!country) return null;
      if (!hasFlag(country)) return country;
      const countryName = getCountryName(country) ?? country;
      return (
      <CountryFlagTooltip title={countryName} placement="top">
      <span>{getUnicodeFlagIcon(country)}</span>
      </CountryFlagTooltip>
      );
      },
      filterFn: 'multiSelect',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_country'),
      filterVariant: 'select',
      style: {
      textAlign: 'center',
      },
      renderSelectOption: (code) => (hasFlag(code) ? getUnicodeFlagIcon(code) : code),
      drawer: {
      label: i18n('drawer_country_label'),
      widget: 'autocompleteObjects',
      options: SUPPLIER_COUNTRY_OPTIONS,
      emptyHelperText: i18n('drawer_country_empty_helper'),
      placeholder: i18n('drawer_country_placeholder'),
      bind: { kind: 'searchFilters', key: 'country' },
      },
      },
      },
      {
      id: 'shipping',
      header: i18n('column_shipping'),
      accessorKey: 'supplierShipping',
      cell: ({ row }: ProductRow) => {
      const shipping = row.original.supplierShipping;
      return shipping ? shippingLabel(shipping) : null;
      },
      filterFn: 'multiSelect',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_shipping'),
      filterVariant: 'select',
      renderSelectOption: (value) => shippingLabel(value),
      drawer: {
      label: i18n('drawer_shipping_label'),
      widget: 'chips',
      options: SHIPPING_OPTIONS,
      formatChipLabel: (option) => shippingLabel(option),
      bind: { kind: 'searchFilters', key: 'shippingType' },
      },
      },
      },
      {
      id: 'availability',
      header: i18n('column_availability'),
      accessorKey: 'availability',
      cell: ({ row }: ProductRow) => {
      const availability = row.original.availability;
      return availability ? availabilityLabel(availability) : null;
      },
      filterFn: 'multiSelect',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_availability'),
      filterVariant: 'select',
      renderSelectOption: (value) => availabilityLabel(value),
      style: {
      textAlign: 'left',
      },
      drawer: {
      label: i18n('drawer_availability_label'),
      widget: 'chips',
      options: AVAILABILITY_OPTIONS,
      formatChipLabel: (option) => availabilityLabel(option),
      bind: { kind: 'searchFilters', key: 'availability' },
      },
      },
      },
      {
      accessorKey: 'description',
      header: i18n('column_description'),
      cell: ({ row }: CellContext<Product, unknown>) => row.original.description,
      meta: {
      truncate: true,
      // Descriptions can be very long; cap the content-fit width so the
      // column truncates with an ellipsis instead of stretching the table.
      autoSizeMax: 180,
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'price',
      // Prices are all converted to the user's selected currency, so surface that
      // code in the header — it disambiguates symbols shared across currencies
      // (e.g. "£" = GBP/GIP/…, "$" = USD/SRD).
      header: ({ table }: HeaderContext<Product, unknown>) =>
      i18n('column_price_currency', [table.options.meta?.userSettings?.currency ?? 'USD']),
      accessorKey: 'price',
      // Read userSettings from table meta rather than context so TableColumns()
      // stays hook-free — it's called from both React renders and non-render
      // code paths (getColumnFilterConfig, DrawerSearchPanel's useMemo), where
      // calling useAppContext() would violate the Rules of Hooks. The shared
      // formatDisplayPrice helper keeps this in sync with the variant list in
      // the expanded detail panel.
      cell: ({ row, table }: CellContext<Product, unknown>) =>
      formatDisplayPrice(row.original, table.options.meta?.userSettings),
      sortingFn: 'priceSortingFn',
      filterFn: 'inNumberRangeHierarchy',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_price'),
      filterVariant: 'range',
      style: {
      textAlign: 'left',
      },
      drawer: {
      label: i18n('drawer_price_label'),
      widget: 'numberRange',
      adornment: 'currency',
      bind: {
      kind: 'userSettingsRange',
      minKey: 'priceMin',
      maxKey: 'priceMax',
      },
      },
      },
      },
      {
      id: 'quantity',
      header: i18n('column_quantity'),
      accessorKey: 'quantity',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_quantity'),
      filterVariant: 'range',
      style: {
      textAlign: 'left',
      },
      },
      cell: ({ row }: ProductRow) => {
      return `${row.original.quantity} ${formatUomForDisplay(row.original.uom)}`;
      },
      sortingFn: 'quantitySortingFn',
      filterFn: 'inNumberRangeHierarchy',
      minSize: 50,
      },
      {
      id: 'uom',
      header: i18n('column_unit'),
      cell: (info) => formatUomForDisplay(info.getValue()),
      accessorKey: 'uom',
      filterFn: 'multiSelect',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_unit'),
      filterVariant: 'select',
      renderSelectOption: (value) => formatUomForDisplay(value),
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'unitPrice',
      header: i18n('column_unit_price'),
      // Numeric value (per base unit) so the column sorts/filters/auto-hides on
      // the amount; the cell renders the "$0.08/g" display string.
      accessorFn: (product) => getUnitPrice(product),
      cell: ({ row, table }: CellContext<Product, unknown>) => {
      const settings = table.options.meta?.userSettings;
      const display = formatUnitPrice(row.original, settings);
      if (!display) return '';
      // When the display was rounded or collapsed to "<$0.01", reveal the
      // full-precision value on hover via a native title tooltip.
      const exact = formatUnitPriceExact(row.original, settings);
      return exact && exact !== display ? <span title={exact}>{display}</span> : display;
      },
      sortingFn: 'unitPriceSortingFn',
      filterFn: 'inNumberRangeHierarchy',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_unit_price'),
      filterVariant: 'range',
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'priceTrend',
      header: i18n('column_price_trend'),
      // Sortable by the trend's signed percent change (rising = +, falling = −),
      // stamped onto the row as `priceTrendValue` by useResultsTable — TanStack
      // can't sort a column without an accessor, and the price history it derives
      // from isn't on the row. `sortUndefined: 'last'` parks trend-less rows at the
      // bottom; `meta.skipEmptyHide` keeps the empty-column auto-hide from dropping
      // this opt-in column when no row currently has a trend.
      accessorFn: (product) => product.priceTrendValue,
      // The cell still draws from the preloaded history map in table meta (the
      // accessor value above is only the number the column sorts on).
      cell: ({ row, table }: CellContext<Product, unknown>) => {
      const history = table.options.meta?.priceHistory;
      if (history === undefined) return null;
      const product = row.original;
      // Match the detail panel's variant set: filtered sub-rows when grouped,
      // else the product's own variants. A flattened variant row (ungrouped) has
      // neither and is resolved by its stamped priceSeriesKey inside the helper.
      const variants: Variant[] =
      row.subRows.length > 0
      ? row.subRows.map((sub) => sub.original)
      : (product.variants ?? []);
      const points = resolveRowTrendPoints(
      product,
      resolveDisplayedVariants(product, variants),
      history,
      );
      if (!points) return null;
      // Hover reveals the same delta/percent badge shown in the variant list.
      // PriceHistoryTooltip's light surface keeps the red/green trend colors
      // legible — the default dark tooltip bubble washes them out.
      return (
      <PriceHistoryTooltip
      title={<PriceTrend points={points} userSettings={table.options.meta?.userSettings} />}
      arrow
      placement="top"
      >
      <span style={{ display: 'inline-flex' }}>
      <PriceSparkline points={points} colorByTrend />
      </span>
      </PriceHistoryTooltip>
      );
      },
      enableColumnFilter: false,
      sortUndefined: 'last',
      size: 96,
      minSize: 96,
      maxSize: 140,
      meta: {
      skipEmptyHide: true,
      style: {
      textAlign: 'center',
      },
      },
      },
      {
      id: 'sds',
      header: i18n('column_sds'),
      cell: ({ row }: ProductRow) => {
      const url = row.original.sdsUrl;
      if (!url) return null;
      return (
      <Link
      href={url}
      aria-label={i18n('product_detail_sds')}
      title={i18n('product_detail_sds')}
      >
      <SDSIcon fontSize="small" />
      </Link>
      );
      },
      //enableSorting: true,
      enableColumnFilter: false,
      minSize: 40,
      maxSize: 40,
      meta: {
      dataKeys: ['sdsUrl'],
      style: {
      textAlign: 'center',
      },
      },
      },
      {
      id: 'specs',
      header: i18n('column_specs'),
      cell: ({ row }: ProductRow) => {
      const url = row.original.specSheetUrl;
      if (!url) return null;
      return (
      <Link
      href={url}
      aria-label={i18n('product_detail_tds')}
      title={i18n('product_detail_tds')}
      >
      <TDSIcon fontSize="small" />
      </Link>
      );
      },
      //enableSorting: true,
      enableColumnFilter: false,
      minSize: 40,
      maxSize: 40,
      meta: {
      dataKeys: ['specSheetUrl'],
      style: {
      textAlign: 'center',
      },
      },
      },
      {
      id: 'coa',
      header: i18n('column_coa'),
      cell: ({ row }: ProductRow) => {
      const url = row.original.coaUrl;
      if (!url) return null;
      return (
      <Link
      href={url}
      aria-label={i18n('product_detail_coa')}
      title={i18n('product_detail_coa')}
      >
      <COAIcon fontSize="small" />
      </Link>
      );
      },
      //enableSorting: true,
      enableColumnFilter: false,
      minSize: 40,
      maxSize: 40,
      meta: {
      dataKeys: ['coaUrl'],
      style: {
      textAlign: 'center',
      },
      },
      },
      {
      id: 'cas',
      header: i18n('column_cas'),
      accessorKey: 'cas',
      // Link the CAS number to PubChem: straight to the compound page when the CID is known,
      // otherwise to a CAS search that resolves to the matching compound.
      cell: ({ row }: ProductRow) => {
      const cas = row.original.cas;
      if (!cas) return null;
      const cid = row.original.pubchemId;
      const href = cid ? pubchemCompoundUrl(cid) : pubchemCasSearchUrl(cas);
      return (
      <Link
      href={href}
      title={i18n('product_detail_pubchem_view')}
      aria-label={`${cas} — ${i18n('product_detail_pubchem_view')}`}
      >
      {cas}
      </Link>
      );
      },
      filterFn: 'includeHierarchy',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_cas'),
      filterVariant: 'text',
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'pubchem',
      header: i18n('column_pubchem'),
      accessorKey: 'pubchemId',
      cell: ({ row }: ProductRow) => {
      const cid = row.original.pubchemId;
      if (!cid) return null;
      return (
      <Link
      href={pubchemCompoundUrl(cid)}
      aria-label={i18n('product_detail_pubchem_cid', [String(cid)])}
      title={i18n('product_detail_pubchem_view')}
      >
      {cid}
      </Link>
      );
      },
      enableColumnFilter: true,
      meta: {
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'formula',
      header: i18n('column_formula'),
      accessorKey: 'formula',
      // The formula is stored with sub/superscripts already converted to
      // unicode (see ProductBuilder.setFormula), so render it as plain text.
      cell: (info) => info.getValue(),
      filterFn: 'includeHierarchy',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_formula'),
      filterVariant: 'text',
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'moleweight',
      header: i18n('column_moleweight'),
      accessorKey: 'moleweight',
      cell: (info) => info.getValue(),
      filterFn: 'inNumberRangeHierarchy',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_moleweight'),
      filterVariant: 'range',
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'purity',
      header: i18n('column_purity'),
      // Prefer the chemical grade (e.g. "ACS"); fall back to the purity % when no grade is
      // set. Using an accessor keeps the displayed value, sorting, and filtering in sync.
      accessorFn: (product) => product.grade ?? product.purity ?? 'Ungraded',
      cell: (info) => {
      const value = info.getValue();
      if (!value || value === 'Ungraded')
      return <span className={styles.ungraded}>{i18n('purity_ungraded')}</span>;
      return value;
      },
      filterFn: 'includeHierarchy',
      // The column mixes grades and percentages, so a string sort would interleave them
      // ("ACS Grade" before "95%"). puritySortingFn ranks both on one numeric scale.
      sortingFn: 'puritySortingFn',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_purity'),
      filterVariant: 'text',
      style: {
      textAlign: 'left',
      },
      },
      },
      {
      id: 'concentration',
      header: i18n('column_concentration'),
      accessorKey: 'concentration',
      cell: (info) => info.getValue(),
      filterFn: 'includeHierarchy',
      meta: {
      filterPlaceholder: i18n('filter_placeholder_concentration'),
      filterVariant: 'text',
      style: {
      textAlign: 'left',
      },
      },
      },
      ];
      }