ChemPal Documentation - v1.7.0
    Preparing search index...
    • Renders one drawer accordion section for a column that declared meta.drawer. The widget (autocompleteStrings, autocompleteObjects, chips, numberRange) determines the input, and config.bind tells the component which slice of app state to read/write.

      Keeps columns free of context knowledge — columns describe what the user sees, this component wires it up to selectedSuppliers, searchFilters, or userSettings via useAppContext.

      Parameters

      • props: ColumnDrawerSectionProps

        Component props.

        • columnId - Column id; the accordion's panelId is derived as search-${columnId}.
        • config - The column's meta.drawer payload.
        • expandedAccordion - Currently expanded accordion's panel id (or false for none).
        • onAccordionChange - MUI Accordion onChange factory; call with the panelId to get the handler.

      Returns null | Element

      An MUI Accordion summary + the widget matching config.widget.

      // Country column — meta.drawer is widget: "autocompleteObjects", bound
      // to searchFilters.country with code/label option objects.
      <ColumnDrawerSection
      columnId="country"
      config={countryDrawerConfig}
      expandedAccordion="search-country"
      onAccordionChange={(panelId) => (_e, isOpen) => setExpanded(isOpen ? panelId : false)}
      />
      // Renders:
      // <Accordion panelId="search-country" expanded>
      // Country (2 selected) ← summary hint updates with value.length
      // <Autocomplete multiple value={[{code:"US",label:"United States"}, ...]}
      // onChange={... setSearchFilters({..., country: codes}) ...} />
      export default function ColumnDrawerSection({
      columnId,
      config,
      expandedAccordion,
      onAccordionChange,
      }: ColumnDrawerSectionProps) {
      const {
      selectedSuppliers,
      setSelectedSuppliers,
      searchFilters,
      setSearchFilters,
      userSettings,
      setUserSettings,
      } = useAppContext();

      // Each supplier's country + shipping scope, used both to grey out suppliers that
      // can't satisfy the active shipping/country filters and to grey out shipping/
      // country options no selected supplier offers. Read once (instantiates suppliers).
      const shippingMeta = useMemo(() => SupplierFactory.supplierShippingMeta(), []);

      // Supplier keys to grey out + disable in the supplier autocomplete: those that
      // won't ship to the user's location (gated by the toggle), plus those ruled out
      // by the active shipping-type / country search filters. Empty unless this is the
      // supplier selector.
      const excludeSuppliers = userSettings.suppliers?.excludeNonShipping ?? true;
      const { location } = userSettings;
      const isSupplierSelector =
      config.widget === 'autocompleteStrings' && config.bind.kind === 'selectedSuppliers';
      const excludedSuppliers = useMemo(() => {
      if (!isSupplierSelector) return new Set<string>();
      const excluded = new Set<string>();
      if (excludeSuppliers && location) {
      for (const [key, ships] of Object.entries(
      SupplierFactory.supplierShipsTo(location as CountryCode),
      )) {
      if (!ships) excluded.add(key);
      }
      }
      for (const key of suppliersExcludedBySearchFilters(shippingMeta, searchFilters)) {
      excluded.add(key);
      }
      return excluded;
      }, [isSupplierSelector, excludeSuppliers, location, shippingMeta, searchFilters]);

      const panelId = `search-${columnId}`;
      const isExpanded = expandedAccordion === panelId;
      const summary = (hint?: ReactNode) => (
      <StyledAccordionSummary expandIcon={<ExpandMoreIcon />}>
      <Typography>
      {config.label}
      {hint !== undefined && <span className={styles['accordion-hint']}>{hint}</span>}
      </Typography>
      </StyledAccordionSummary>
      );

      // autocompleteStrings — e.g. Search Suppliers (keys: string[]).
      if (config.widget === 'autocompleteStrings') {
      if (config.bind.kind !== 'selectedSuppliers' && config.bind.kind !== 'searchFilters') {
      return null;
      }

      const { options, optionLabels, emptyHelperText, placeholder } = config;
      // `selectedSuppliers` and per-filter arrays may be undefined before the
      // mount hydration lands — coalesce to [] so the Autocomplete + summary
      // code below can treat `currentValue` as a concrete array uniformly.
      const currentValue: string[] =
      config.bind.kind === 'selectedSuppliers'
      ? (selectedSuppliers ?? [])
      : // `bind.key` is `keyof SearchFilters`, whose values are `string |
      // string[]`; this widget only binds to the `string[]` filters.
      ((searchFilters[config.bind.key] as string[] | undefined) ?? []);

      const handleChange = (_event: SyntheticEvent, newValue: string[]) => {
      if (config.bind.kind === 'selectedSuppliers') {
      // Autocomplete yields plain strings; keep only valid supplier names.
      setSelectedSuppliers(newValue.filter(SupplierFactory.isSupplierClassName));
      } else if (config.bind.kind === 'searchFilters') {
      setSearchFilters({ ...searchFilters, [config.bind.key]: newValue });
      }
      };

      return (
      <Accordion expanded={isExpanded} onChange={onAccordionChange(panelId)}>
      {summary(currentValue.length > 0 ? ` (${currentValue.length} selected)` : undefined)}
      <StyledAccordionDetails>
      <Autocomplete
      multiple
      size="small"
      disableCloseOnSelect
      options={[...options]}
      getOptionLabel={(option) => optionLabels?.[option] ?? option}
      filterOptions={(opts, { inputValue }) => {
      const term = inputValue.toLowerCase();
      return opts.filter(
      (opt) =>
      !currentValue.includes(opt) &&
      (optionLabels?.[opt] ?? opt).toLowerCase().includes(term),
      );
      }}
      value={currentValue}
      onChange={handleChange}
      getOptionDisabled={(option) => excludedSuppliers.has(option)}
      renderOption={(props, option) => {
      // MUI passes `key` inside props; pull it out to set it explicitly.
      const { key, ...optionProps } = props;
      const excluded = excludedSuppliers.has(option);
      return (
      <Box
      component="li"
      key={key}
      {...optionProps}
      sx={excluded ? { fontStyle: 'italic', color: 'text.disabled' } : undefined}
      >
      {optionLabels?.[option] ?? option}
      </Box>
      );
      }}
      renderInput={(params) => (
      <TextField
      {...params}
      label={i18n('filter_by_label', [config.label.toLowerCase()])}
      placeholder={placeholder}
      helperText={currentValue.length === 0 ? emptyHelperText : undefined}
      slotProps={{ formHelperText: { sx: { fontStyle: 'italic' } } }}
      />
      )}
      />
      {isSupplierSelector && (
      <FormControlLabel
      sx={{ mt: 1 }}
      control={
      <Switch
      size="small"
      checked={excludeSuppliers}
      onChange={(e) =>
      setUserSettings({
      ...userSettings,
      suppliers: {
      ...userSettings.suppliers,
      excludeNonShipping: e.target.checked,
      },
      })
      }
      />
      }
      label={i18n('drawer_only_shipping_suppliers')}
      />
      )}
      {isSupplierSelector && (
      <FormControlLabel
      sx={{ mt: 1 }}
      control={
      <Switch
      size="small"
      checked={userSettings.hideRestrictedProducts ?? true}
      onChange={(e) =>
      setUserSettings({
      ...userSettings,
      hideRestrictedProducts: e.target.checked,
      })
      }
      />
      }
      label={i18n('drawer_hide_restricted_products')}
      />
      )}
      </StyledAccordionDetails>
      </Accordion>
      );
      }

      // autocompleteObjects — e.g. Country (options are { code, label }).
      if (config.widget === 'autocompleteObjects') {
      if (config.bind.kind !== 'searchFilters') return null;
      const bindKey = config.bind.key;
      const { options, emptyHelperText, placeholder } = config;
      // `bindKey` is `keyof SearchFilters` (values `string | string[]`); this
      // widget only binds to the `string[]` filters.
      const selectedCodes = searchFilters[bindKey] as string[];
      const currentValue: CountryOption[] = options.filter((opt) => selectedCodes.includes(opt.code));

      // When suppliers are selected and this is the country filter, grey out
      // countries none of the selected suppliers reside in — they could never match.
      const suppliers = selectedSuppliers ?? [];
      const offeredCountries =
      bindKey === 'country' && suppliers.length > 0
      ? new Set<string>(countriesForSuppliers(shippingMeta, suppliers))
      : undefined;

      const handleChange = (_event: SyntheticEvent, newValue: CountryOption[]) => {
      setSearchFilters({
      ...searchFilters,
      [bindKey]: newValue.map((opt) => opt.code),
      });
      };

      return (
      <Accordion expanded={isExpanded} onChange={onAccordionChange(panelId)}>
      {summary(selectedCodes.length > 0 ? ` (${selectedCodes.length} selected)` : undefined)}
      <StyledAccordionDetails>
      <Autocomplete
      multiple
      size="small"
      disableCloseOnSelect
      options={[...options]}
      getOptionLabel={(option) => option.label}
      filterOptions={(opts, { inputValue }) => {
      const term = inputValue.toLowerCase();
      return opts.filter(
      (opt) =>
      !selectedCodes.includes(opt.code) &&
      (opt.label.toLowerCase().includes(term) || opt.code.toLowerCase().includes(term)),
      );
      }}
      value={currentValue}
      onChange={handleChange}
      isOptionEqualToValue={(option, value) => option.code === value.code}
      getOptionDisabled={(option) =>
      offeredCountries !== undefined && !offeredCountries.has(option.code)
      }
      renderInput={(params) => (
      <TextField
      {...params}
      label={i18n('filter_by_label', [config.label.toLowerCase()])}
      placeholder={placeholder}
      helperText={selectedCodes.length === 0 ? emptyHelperText : undefined}
      slotProps={{ formHelperText: { sx: { fontStyle: 'italic' } } }}
      />
      )}
      />
      </StyledAccordionDetails>
      </Accordion>
      );
      }

      // chips — e.g. Shipping Type (chip toggle for a fixed string list).
      if (config.widget === 'chips') {
      if (config.bind.kind !== 'searchFilters') return null;
      const bindKey = config.bind.key;
      const { options, formatChipLabel } = config;
      // `bindKey` is `keyof SearchFilters` (values `string | string[]`); this
      // widget only binds to the `string[]` filters.
      const selected = searchFilters[bindKey] as string[];

      // Only the shipping-type filter is constrained by supplier selection
      // (availability is independent of suppliers): once suppliers are selected,
      // grey out shipping scopes none of them can fulfill (respecting the hierarchy,
      // so a domestic supplier still enables "local") — but never a currently-selected
      // chip, so the user can always toggle it back off.
      const suppliers = selectedSuppliers ?? [];
      const fulfillable =
      bindKey === 'shippingType' && suppliers.length > 0
      ? new Set<string>(fulfillableShippingRanges(shippingMeta, suppliers))
      : undefined;
      const isOptionDisabled = (option: string) =>
      fulfillable !== undefined && !fulfillable.has(option) && !selected.includes(option);

      const toggle = (value: string) => {
      const next = selected.includes(value)
      ? selected.filter((item) => item !== value)
      : [...selected, value];
      setSearchFilters({ ...searchFilters, [bindKey]: next });
      };

      return (
      <Accordion expanded={isExpanded} onChange={onAccordionChange(panelId)}>
      {summary(selected.length > 0 ? ` (${selected.length} selected)` : undefined)}
      <StyledAccordionDetails>
      <Box className={styles['chip-container']}>
      {options.map((option) => (
      <Chip
      key={option}
      label={formatChipLabel ? formatChipLabel(option) : option}
      size="small"
      disabled={isOptionDisabled(option)}
      onClick={() => toggle(option)}
      color={selected.includes(option) ? 'primary' : 'default'}
      variant={selected.includes(option) ? 'filled' : 'outlined'}
      />
      ))}
      </Box>
      </StyledAccordionDetails>
      </Accordion>
      );
      }

      // numberRange — e.g. Price Range (two numeric inputs with optional adornment).
      if (config.widget === 'numberRange') {
      if (config.bind.kind !== 'userSettingsRange') return null;
      const { minKey, maxKey } = config.bind;
      // `minKey`/`maxKey` are `keyof UserSettings` (a heterogeneous interface);
      // this widget only binds them to the numeric range settings.
      const minValue = userSettings[minKey] as number | undefined;
      const maxValue = userSettings[maxKey] as number | undefined;
      // Resolve the `"currency"` sentinel at render time so the symbol follows
      // the user's current currency setting (USD → "$", EUR → "€", etc.).
      const adornment =
      config.adornment === 'currency'
      ? userSettings.currency
      ? CURRENCY_SYMBOL_MAP[userSettings.currency]
      : undefined
      : config.adornment;

      const hint =
      minValue != null || maxValue != null
      ? ` (${
      minValue != null && maxValue != null
      ? `${adornment ?? ''}${minValue} - ${adornment ?? ''}${maxValue}`
      : minValue != null
      ? i18n('results_column_filter_number_min', [adornment ?? '' + minValue])
      : i18n('results_column_filter_number_max', [adornment ?? '' + maxValue])
      })`
      : undefined;

      const handleNumberChange = (key: keyof UserSettings) => (e: ChangeEvent<HTMLInputElement>) => {
      // Blank or malformed input clears the bound setting rather than storing NaN.
      setUserSettings({ ...userSettings, [key]: toFiniteNumber(e.target.value) });
      };

      return (
      <Accordion expanded={isExpanded} onChange={onAccordionChange(panelId)}>
      {summary(hint)}
      <StyledAccordionDetails>
      <Box sx={{ display: 'flex', gap: 2 }}>
      <TextField
      label={i18n('drawer_range_min')}
      type="number"
      size="small"
      value={minValue ?? ''}
      onChange={handleNumberChange(minKey)}
      slotProps={{
      input: adornment
      ? {
      startAdornment: <InputAdornment position="start">{adornment}</InputAdornment>,
      }
      : undefined,
      htmlInput: { min: 0 },
      }}
      />
      <TextField
      label={i18n('drawer_range_max')}
      type="number"
      size="small"
      value={maxValue ?? ''}
      onChange={handleNumberChange(maxKey)}
      slotProps={{
      input: adornment
      ? {
      startAdornment: <InputAdornment position="start">{adornment}</InputAdornment>,
      }
      : undefined,
      htmlInput: { min: 0 },
      }}
      />
      </Box>
      </StyledAccordionDetails>
      </Accordion>
      );
      }

      return null;
      }