ChemPal Documentation - v0.0.13-beta.5
    Preparing search index...
    • The full settings panel shown in the drawer's Settings tab. Renders all user preference controls (currencies, locations, toggles, numeric limits, excluded products, etc.) and persists changes to userSettings via app context.

      Returns Element

      The settings panel element.

      // Rendered inside the Settings drawer tab.
      <SettingsPanelFull />
      export default function SettingsPanelFull() {
      const appContext = useAppContext();
      const [expanded, setExpanded] = useState<string | false>("behavior");

      if (!appContext) {
      return <div>Loading settings...</div>;
      }

      const [formState, updateSetting, isPending] = useActionState(
      (currentSettings: UserSettings, action: SettingAction): UserSettings => {
      let newSettings: UserSettings;
      switch (action.type) {
      case ACTION_TYPE.SWITCH_CHANGE:
      newSettings = { ...currentSettings, [action.name]: action.checked };
      break;
      case ACTION_TYPE.INPUT_CHANGE:
      newSettings = { ...currentSettings, [action.name]: action.value };
      break;
      case ACTION_TYPE.BUTTON_CLICK:
      newSettings = { ...currentSettings, [action.name]: action.value };
      break;
      case ACTION_TYPE.RESTORE_DEFAULTS:
      newSettings = {
      ...currentSettings,
      showHelp: false,
      caching: true,
      showColumnFilters: true,
      showAllColumns: false,
      fontSize: "medium",
      hideColumns: ["description", "uom"],
      };
      break;
      default:
      return currentSettings;
      }
      startTransition(() => {
      try {
      appContext.setUserSettings(newSettings);
      } catch (error) {
      console.error("Failed to update settings:", error);
      }
      });
      return newSettings;
      },
      appContext.userSettings,
      );

      const handleSwitchChange = (event: ChangeEvent<HTMLInputElement>) => {
      updateSetting({
      type: ACTION_TYPE.SWITCH_CHANGE,
      name: event.target.name,
      checked: event.target.checked,
      });
      };

      const handleInputChange = (
      event: SelectChangeEvent | ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
      ) => {
      updateSetting({
      type: ACTION_TYPE.INPUT_CHANGE,
      name: event.target.name,
      value: event.target.value,
      });
      };

      const handleButtonClick = (event: MouseEvent<HTMLDivElement>) => {
      if (!isButtonElement(event.target)) return;
      const { name, value } = event.target;
      if (name && value) {
      updateSetting({ type: ACTION_TYPE.BUTTON_CLICK, name, value });
      }
      };

      const handleRestoreDefaults = () => {
      updateSetting({ type: ACTION_TYPE.RESTORE_DEFAULTS });
      };

      const [excludedProducts, setExcludedProducts] = useState<ExcludedProductsMap>({});

      useEffect(() => {
      const load = async () => {
      try {
      const map = await loadExcludedProducts();
      setExcludedProducts(map);
      } catch (error) {
      console.warn("Failed to load excluded products:", error);
      }
      };
      load();
      }, []);

      const handleRemoveExcluded = async (key: string) => {
      try {
      await removeExcludedProduct(key);
      setExcludedProducts((prev) => {
      const next = { ...prev };
      delete next[key];
      return next;
      });
      } catch (error) {
      console.warn("Failed to remove excluded product:", error);
      }
      };

      const handleClearAllExcluded = async () => {
      try {
      await clearExcludedProducts();
      setExcludedProducts({});
      } catch (error) {
      console.warn("Failed to clear excluded products:", error);
      }
      };

      const excludedEntries = Object.entries(excludedProducts).sort(
      ([, a], [, b]) => b.excludedAt - a.excludedAt,
      );
      const excludedCount = excludedEntries.length;

      const currentSettings = formState || appContext.userSettings;

      const handleAccordionChange =
      (panel: string) => (_event: SyntheticEvent, isExpanded: boolean) => {
      setExpanded(isExpanded ? panel : false);
      };

      return (
      <Box>
      <Accordion
      expanded={expanded === "behavior"}
      onChange={handleAccordionChange("behavior")}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles["settings-panel__accordion-summary"]}
      >
      <Typography variant="body2" fontWeight={500}>
      Behavior
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles["settings-panel__accordion-details"]}>
      <List dense component="nav" aria-labelledby="behavior-list-subheader">
      {/* Caching */}
      <ListItem className={styles["settings-panel__helper-on-hover"]}>
      <ListItemText primary="Cache Search Results" />
      {/*<FormHelperText>Improves performance</FormHelperText>*/}
      <FormControlLabel
      control={
      <Switch
      checked={currentSettings.caching}
      onChange={handleSwitchChange}
      name="caching"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      {/* Currency */}
      <ListItem className={styles["settings-panel__helper-on-hover"]}>
      <ListItemText primary="Currency" />
      {/*<FormHelperText>Convert all currency to this</FormHelperText>*/}
      <FormControl>
      <Select
      value={currentSettings.currency}
      onChange={handleInputChange}
      name="currency"
      size="small"
      className={styles["settings-panel__input"]}
      disabled={isPending}
      >
      {Object.entries(currencies).map(([currencyId, { symbol }]) => (
      <MenuItem key={currencyId} value={currencyId}>
      {currencyId.toUpperCase()} ({symbol})
      </MenuItem>
      ))}
      </Select>
      </FormControl>
      </ListItem>
      {/* Location */}
      <ListItem className={styles["settings-panel__helper-on-hover"]}>
      <ListItemText primary="Location" />
      {/*<FormHelperText>Your country</FormHelperText>*/}
      <FormControl>
      <Select
      value={currentSettings.location}
      onChange={handleInputChange}
      name="location"
      size="small"
      className={styles["settings-panel__input"]}
      disabled={isPending}
      >
      <MenuItem value="">
      <i>None</i>
      </MenuItem>
      {Object.entries(locations).map(([locationId, { name }]) => (
      <MenuItem key={locationId} value={locationId}>
      {name}
      </MenuItem>
      ))}
      </Select>
      </FormControl>
      </ListItem>

      {/* Foo Example */}
      <ListItem className={styles["settings-panel__helper-on-hover"]}>
      <ListItemText primary="Currency Rate" />
      {/*<FormHelperText>Just an input example</FormHelperText>*/}
      <FormControl>
      <TextField
      value={currentSettings.currencyRate}
      name="currencyRate"
      onChange={handleInputChange}
      variant="outlined"
      size="small"
      className={styles["settings-panel__input"]}
      disabled={isPending}
      />
      </FormControl>
      </ListItem>
      </List>
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === "cache"}
      onChange={handleAccordionChange("cache")}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles["settings-panel__accordion-summary"]}
      >
      <Typography variant="body2" fontWeight={500}>
      Cache
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles["settings-panel__accordion-details"]}>
      <List dense component="nav" aria-labelledby="cache-list-subheader">
      {/* Do Not Cache Empty Results */}
      <ListItem className={styles["settings-panel__helper-on-hover"]}>
      <ListItemText primary="Do Not Cache Empty Results" />
      <FormControlLabel
      control={
      <Switch
      checked={Boolean(currentSettings.doNotCacheEmptyResults)}
      onChange={handleSwitchChange}
      name="doNotCacheEmptyResults"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      {/* Cache TTL (minutes) — 0 disables expiration */}
      <ListItem className={styles["settings-panel__helper-on-hover"]}>
      <ListItemText primary="Cache TTL (minutes)" />
      <FormControl>
      <TextField
      value={currentSettings.cacheTtlMinutes ?? 0}
      name="cacheTtlMinutes"
      onChange={handleInputChange}
      type="number"
      variant="outlined"
      size="small"
      className={styles["settings-panel__input"]}
      disabled={isPending}
      slotProps={{ htmlInput: { min: 0, step: 1 } }}
      />
      </FormControl>
      </ListItem>
      </List>
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === "display"}
      onChange={handleAccordionChange("display")}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles["settings-panel__accordion-summary"]}
      >
      <Typography variant="body2" fontWeight={500}>
      Display
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles["settings-panel__accordion-details"]}>
      <List dense component="nav" aria-labelledby="display-list-subheader">
      {/* Font Size */}
      <ListItem className={styles["settings-panel__helper-on-hover"]}>
      <ListItemText primary="Font Size" />
      {/*<FormHelperText>Popup size</FormHelperText>*/}
      <FormControl>
      <ButtonGroup
      variant="contained"
      aria-label="Basic button group"
      onClick={handleButtonClick}
      disabled={isPending}
      >
      <Button
      name="fontSize"
      value="small"
      size="small"
      aria-label="Small"
      title="Small"
      variant={currentSettings.fontSize === "small" ? "contained" : "text"}
      disabled={isPending}
      >
      <TextDecreaseIcon fontSize="small" sx={{ pointerEvents: "none" }} />
      </Button>
      <Button
      name="fontSize"
      value="medium"
      size="small"
      aria-label="Medium"
      title="Medium"
      variant={currentSettings.fontSize === "medium" ? "contained" : "text"}
      disabled={isPending}
      >
      <TextFormatIcon fontSize="small" sx={{ pointerEvents: "none" }} />
      </Button>
      <Button
      name="fontSize"
      value="large"
      size="small"
      aria-label="Large"
      title="Large"
      variant={currentSettings.fontSize === "large" ? "contained" : "text"}
      disabled={isPending}
      >
      <TextIncreaseIcon fontSize="small" sx={{ pointerEvents: "none" }} />
      </Button>
      </ButtonGroup>
      </FormControl>
      </ListItem>
      </List>
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === "excluded"}
      onChange={handleAccordionChange("excluded")}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles["settings-panel__accordion-summary"]}
      >
      <Typography variant="body2" fontWeight={500}>
      Excluded Products
      {excludedCount > 0 && (
      <Typography
      component="span"
      variant="caption"
      color="text.secondary"
      sx={{ ml: 0.5 }}
      >
      ({excludedCount})
      </Typography>
      )}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles["settings-panel__accordion-details"]}>
      {excludedCount === 0 ? (
      <Typography
      variant="caption"
      color="text.secondary"
      className={styles["settings-panel__excluded-empty"]}
      >
      No excluded products.
      </Typography>
      ) : (
      <>
      <List dense disablePadding>
      {excludedEntries.map(([key, entry]) => (
      <ListItem
      key={key}
      divider
      className={styles["settings-panel__excluded-item"]}
      secondaryAction={
      <Tooltip title="Remove">
      <IconButton
      edge="end"
      size="small"
      onClick={() => handleRemoveExcluded(key)}
      className={styles["settings-panel__excluded-delete-btn"]}
      aria-label={`Remove ${entry.title || entry.url}`}
      >
      <DeleteIcon className={styles["settings-panel__excluded-delete-icon"]} />
      </IconButton>
      </Tooltip>
      }
      >
      <ListItemText
      primary={
      <Link
      href={entry.url}
      target="_blank"
      rel="noopener noreferrer"
      variant="body2"
      className={styles["settings-panel__excluded-link"]}
      >
      {entry.title || entry.url}
      </Link>
      }
      secondary={`${entry.supplier} — ${formatTimestamp(entry.excludedAt)}`}
      slotProps={{
      secondary: {
      variant: "caption",
      className: styles["settings-panel__excluded-secondary-text"],
      },
      }}
      />
      </ListItem>
      ))}
      </List>
      <Box className={styles["settings-panel__excluded-actions"]}>
      <Button
      variant="outlined"
      color="warning"
      size="small"
      onClick={handleClearAllExcluded}
      >
      Clear All
      </Button>
      </Box>
      </>
      )}
      </AccordionDetails>
      </Accordion>
      {/* Dev-only Advanced section. `IS_DEV_BUILD` is a Vite-replaced string
      literal, so the entire block is tree-shaken from prod bundles — no
      config flag or runtime check reaches production users. */}
      {IS_DEV_BUILD && (
      <Accordion
      expanded={expanded === "advanced"}
      onChange={handleAccordionChange("advanced")}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles["settings-panel__accordion-summary"]}
      >
      <Typography variant="body2" fontWeight={500}>
      Advanced
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles["settings-panel__accordion-details"]}>
      {/* Mirrors the search drawer's single-select filter-input style:
      full-width labeled outlined TextField with italic helper text.
      Using `TextField select` (rather than the horizontal
      ListItem + Select pattern the rest of this panel uses) gives
      long scorer names like `partial_token_similarity_sort_ratio`
      room to render, and keeps the visual consistent with the
      search drawer filters. */}
      <Box sx={{ p: 1 }}>
      <TextField
      select
      fullWidth
      size="small"
      name="fuzzScorerOverride"
      label="Fuzz match method"
      value={currentSettings.fuzzScorerOverride ?? ""}
      onChange={handleInputChange}
      disabled={isPending}
      helperText="Overrides each supplier's default scorer"
      slotProps={{ formHelperText: { sx: { fontStyle: "italic" } } }}
      >
      <MenuItem value="">
      <em>Default (per supplier)</em>
      </MenuItem>
      {FUZZ_SCORER_NAMES.map((name) => (
      <MenuItem key={name} value={name}>
      {name}
      </MenuItem>
      ))}
      </TextField>
      </Box>
      </AccordionDetails>
      </Accordion>
      )}
      <Accordion
      expanded={expanded === "actions"}
      onChange={handleAccordionChange("actions")}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles["settings-panel__accordion-summary"]}
      >
      <Typography variant="body2" fontWeight={500}>
      Actions
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles["settings-panel__accordion-details--actions"]}>
      <Stack direction="row" spacing={2}>
      <Button
      variant="outlined"
      color="primary"
      onClick={handleRestoreDefaults}
      disabled={isPending}
      >
      Restore Defaults
      </Button>
      </Stack>
      </AccordionDetails>
      </Accordion>
      </Box>
      );
      }