awa-ui 0.5.2

Web UI and JSON API for the Awa job queue
Documentation
import { useState, useCallback, useRef, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchDistinctKinds, fetchDistinctQueues } from "@/lib/api";
import { POLL, MAX_SUGGESTIONS } from "@/lib/constants";

interface SearchFilters {
  kind?: string;
  queue?: string;
  tag?: string;
}

/** Parse a search string that may contain kind:, queue:, tag: prefixes. */
export function parseSearch(search: string): SearchFilters {
  const filters: SearchFilters = {};
  const parts = search.trim().split(/\s+/);
  for (const part of parts) {
    if (part.startsWith("kind:") && part.length > 5) {
      filters.kind = part.slice(5);
    } else if (part.startsWith("queue:") && part.length > 6) {
      filters.queue = part.slice(6);
    } else if (part.startsWith("tag:") && part.length > 4) {
      filters.tag = part.slice(4);
    }
  }
  return filters;
}

/** Build a search string from individual filter values. */
function buildSearch(filters: SearchFilters): string {
  const parts: string[] = [];
  if (filters.kind) parts.push(`kind:${filters.kind}`);
  if (filters.queue) parts.push(`queue:${filters.queue}`);
  if (filters.tag) parts.push(`tag:${filters.tag}`);
  return parts.join(" ");
}

interface Suggestion {
  label: string;
  value: string;
}

interface SearchBarProps {
  /** The full filter string (e.g. "queue:ui_demo kind:data_import") */
  value: string;
  onChange: (value: string) => void;
}

/**
 * Filter bar with chip-based applied filters and autocomplete input.
 * Applied filters appear as removable chips. The text input is for
 * adding new filters only — it clears after each filter is applied.
 */
export function SearchBar({ value, onChange }: SearchBarProps) {
  const [inputValue, setInputValue] = useState("");
  const [showSuggestions, setShowSuggestions] = useState(false);
  const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
  const [selectedIdx, setSelectedIdx] = useState(-1);
  const containerRef = useRef<HTMLDivElement>(null);

  const activeFilters = parseSearch(value);

  const kindsQuery = useQuery<string[]>({
    queryKey: ["distinct-kinds"],
    queryFn: fetchDistinctKinds,
    staleTime: POLL.SLOW,
    refetchInterval: false,
  });

  const queuesQuery = useQuery<string[]>({
    queryKey: ["distinct-queues"],
    queryFn: fetchDistinctQueues,
    staleTime: POLL.SLOW,
    refetchInterval: false,
  });

  // Global "/" shortcut to focus search
  useEffect(() => {
    function handleGlobalKeyDown(e: KeyboardEvent) {
      if (
        e.key === "/" &&
        !e.ctrlKey &&
        !e.metaKey &&
        document.activeElement?.tagName !== "INPUT" &&
        document.activeElement?.tagName !== "TEXTAREA"
      ) {
        e.preventDefault();
        containerRef.current?.querySelector("input")?.focus();
      }
    }
    document.addEventListener("keydown", handleGlobalKeyDown);
    return () => document.removeEventListener("keydown", handleGlobalKeyDown);
  }, []);

  const computeSuggestions = useCallback(
    (text: string) => {
      const results: Suggestion[] = [];
      const trimmed = text.trim();

      if (trimmed.startsWith("kind:")) {
        const prefix = trimmed.slice(5).toLowerCase();
        for (const kind of kindsQuery.data ?? []) {
          if (kind.toLowerCase().includes(prefix)) {
            results.push({ label: `kind:${kind}`, value: `kind:${kind}` });
          }
        }
      } else if (trimmed.startsWith("queue:")) {
        const prefix = trimmed.slice(6).toLowerCase();
        for (const queue of queuesQuery.data ?? []) {
          if (queue.toLowerCase().includes(prefix)) {
            results.push({ label: `queue:${queue}`, value: `queue:${queue}` });
          }
        }
      } else if (trimmed.startsWith("tag:")) {
        // No autocomplete for tags
      } else {
        // Show prefix hints for filters not yet applied
        if (!activeFilters.kind) {
          results.push({ label: "kind:<name>", value: "kind:" });
        }
        if (!activeFilters.queue) {
          results.push({ label: "queue:<name>", value: "queue:" });
        }
        if (!activeFilters.tag) {
          results.push({ label: "tag:<name>", value: "tag:" });
        }
      }

      return results.slice(0, MAX_SUGGESTIONS);
    },
    [kindsQuery.data, queuesQuery.data, activeFilters]
  );

  const handleInputChange = (text: string) => {
    setInputValue(text);
    const suggs = computeSuggestions(text);
    setSuggestions(suggs);
    setShowSuggestions(suggs.length > 0);
    setSelectedIdx(-1);
  };

  /** Apply a filter token — merges into existing filters, clears input */
  const applyToken = useCallback(
    (token: string) => {
      const parsed = parseSearch(token);
      const merged = { ...activeFilters, ...parsed };
      onChange(buildSearch(merged));
      setInputValue("");
      setShowSuggestions(false);
      setSelectedIdx(-1);
    },
    [activeFilters, onChange]
  );

  /** Remove a single filter */
  const removeFilter = useCallback(
    (key: keyof SearchFilters) => {
      const next = { ...activeFilters };
      delete next[key];
      onChange(buildSearch(next));
    },
    [activeFilters, onChange]
  );

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (showSuggestions && suggestions.length > 0) {
      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          setSelectedIdx((prev) =>
            prev < suggestions.length - 1 ? prev + 1 : 0
          );
          return;
        case "ArrowUp":
          e.preventDefault();
          setSelectedIdx((prev) =>
            prev > 0 ? prev - 1 : suggestions.length - 1
          );
          return;
        case "Escape":
          setShowSuggestions(false);
          setSelectedIdx(-1);
          return;
        case "Enter": {
          e.preventDefault();
          if (selectedIdx >= 0 && selectedIdx < suggestions.length) {
            const s = suggestions[selectedIdx];
            if (s && !s.value.endsWith(":")) {
              applyToken(s.value);
            } else if (s) {
              setInputValue(s.value);
              const suggs = computeSuggestions(s.value);
              setSuggestions(suggs);
              setSelectedIdx(-1);
            }
          } else if (inputValue.trim()) {
            applyToken(inputValue.trim());
          }
          return;
        }
      }
    }

    if (e.key === "Enter" && inputValue.trim()) {
      e.preventDefault();
      applyToken(inputValue.trim());
    }

    // Backspace on empty input removes last filter
    if (e.key === "Backspace" && inputValue === "") {
      const keys = (Object.keys(activeFilters) as (keyof SearchFilters)[]).filter(
        (k) => activeFilters[k]
      );
      if (keys.length > 0) {
        removeFilter(keys[keys.length - 1]!);
      }
    }
  };

  // Close dropdown on click outside
  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (
        containerRef.current &&
        !containerRef.current.contains(event.target as Node)
      ) {
        setShowSuggestions(false);
        setSelectedIdx(-1);
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  const hasFilters =
    activeFilters.kind || activeFilters.queue || activeFilters.tag;

  return (
    <div ref={containerRef} className="relative">
      {/* Chip input container — looks like one field */}
      <div
        className="flex flex-wrap items-center gap-1.5 rounded-lg border border-input bg-bg px-3 py-1.5 text-sm focus-within:ring-2 focus-within:ring-ring/20 focus-within:border-ring"
        onClick={() => containerRef.current?.querySelector("input")?.focus()}
      >
        {/* Applied filter chips */}
        {activeFilters.kind && (
          <FilterChip
            label={`kind:${activeFilters.kind}`}
            onRemove={() => removeFilter("kind")}
          />
        )}
        {activeFilters.queue && (
          <FilterChip
            label={`queue:${activeFilters.queue}`}
            onRemove={() => removeFilter("queue")}
          />
        )}
        {activeFilters.tag && (
          <FilterChip
            label={`tag:${activeFilters.tag}`}
            onRemove={() => removeFilter("tag")}
          />
        )}

        {/* Text input */}
        <input
          type="text"
          value={inputValue}
          onChange={(e) => handleInputChange(e.target.value)}
          onFocus={() => {
            const suggs = computeSuggestions(inputValue);
            setSuggestions(suggs);
            setShowSuggestions(suggs.length > 0);
          }}
          onKeyDown={handleKeyDown}
          placeholder={hasFilters ? "Add filter..." : "Filter by kind:, queue:, tag: ...  (press /)"}
          className="min-w-[120px] flex-1 border-0 bg-transparent p-0 text-sm text-fg outline-none placeholder:text-muted-fg"
          aria-label="Search jobs"
        />
      </div>

      {/* Suggestions dropdown */}
      {showSuggestions && suggestions.length > 0 && (
        <ul
          className="absolute z-20 mt-1 w-full rounded-lg border border-border bg-overlay p-1 shadow-lg"
          role="listbox"
        >
          {suggestions.map((suggestion, index) => (
            <li key={index} role="option" aria-selected={index === selectedIdx}>
              <button
                type="button"
                className={[
                  "w-full cursor-pointer rounded-md px-3 py-1.5 text-left text-sm",
                  index === selectedIdx
                    ? "bg-primary text-primary-fg"
                    : "text-fg hover:bg-secondary",
                ].join(" ")}
                onMouseDown={(e) => {
                  e.preventDefault();
                  if (!suggestion.value.endsWith(":")) {
                    applyToken(suggestion.value);
                  } else {
                    setInputValue(suggestion.value);
                    const suggs = computeSuggestions(suggestion.value);
                    setSuggestions(suggs);
                    setSelectedIdx(-1);
                  }
                }}
                onMouseEnter={() => setSelectedIdx(index)}
              >
                {suggestion.label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

/** A removable filter chip */
function FilterChip({
  label,
  onRemove,
}: {
  label: string;
  onRemove: () => void;
}) {
  return (
    <span className="inline-flex items-center gap-1 rounded-md bg-primary-subtle px-2 py-0.5 text-xs font-medium text-primary-subtle-fg">
      {label}
      <button
        type="button"
        onClick={(e) => {
          e.stopPropagation();
          onRemove();
        }}
        className="ml-0.5 rounded-sm hover:bg-primary-subtle-fg/10"
        aria-label={`Remove ${label} filter`}
      >
        <svg className="size-3" viewBox="0 0 16 16" fill="currentColor">
          <path d="M12.207 4.793a1 1 0 0 1 0 1.414L9.414 9l2.793 2.793a1 1 0 0 1-1.414 1.414L8 10.414l-2.793 2.793a1 1 0 0 1-1.414-1.414L6.586 9 3.793 6.207a1 1 0 0 1 1.414-1.414L8 7.586l2.793-2.793a1 1 0 0 1 1.414 0z" />
        </svg>
      </button>
    </span>
  );
}