diff --git a/.cursor/rules/cursor_rules.mdc b/.cursor/rules/cursor_rules.mdc new file mode 100644 index 00000000..7dfae3de --- /dev/null +++ b/.cursor/rules/cursor_rules.mdc @@ -0,0 +1,53 @@ +--- +description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness. +globs: .cursor/rules/*.mdc +alwaysApply: true +--- + +- **Required Rule Structure:** + ```markdown + --- + description: Clear, one-line description of what the rule enforces + globs: path/to/files/*.ext, other/path/**/* + alwaysApply: boolean + --- + + - **Main Points in Bold** + - Sub-points with details + - Examples and explanations + ``` + +- **File References:** + - Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files + - Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references + - Example: [schema.prisma](mdc:prisma/schema.prisma) for code references + +- **Code Examples:** + - Use language-specific code blocks + ```typescript + // ✅ DO: Show good examples + const goodExample = true; + + // ❌ DON'T: Show anti-patterns + const badExample = false; + ``` + +- **Rule Content Guidelines:** + - Start with high-level overview + - Include specific, actionable requirements + - Show examples of correct implementation + - Reference existing code when possible + - Keep rules DRY by referencing other rules + +- **Rule Maintenance:** + - Update rules when new patterns emerge + - Add examples from actual codebase + - Remove outdated patterns + - Cross-reference related rules + +- **Best Practices:** + - Use bullet points for clarity + - Keep descriptions concise + - Include both DO and DON'T examples + - Reference actual code over theoretical examples + - Use consistent formatting across rules \ No newline at end of file diff --git a/.cursor/rules/dev_workflow.mdc b/.cursor/rules/dev_workflow.mdc new file mode 100644 index 00000000..4d430323 --- /dev/null +++ b/.cursor/rules/dev_workflow.mdc @@ -0,0 +1,219 @@ +--- +description: Guide for using Task Master to manage task-driven development workflows +globs: **/* +alwaysApply: true +--- +# Task Master Development Workflow + +This guide outlines the typical process for using Task Master to manage software development projects. + +## Primary Interaction: MCP Server vs. CLI + +Task Master offers two primary ways to interact: + +1. **MCP Server (Recommended for Integrated Tools)**: + - For AI agents and integrated development environments (like Cursor), interacting via the **MCP server is the preferred method**. + - The MCP server exposes Task Master functionality through a set of tools (e.g., `get_tasks`, `add_subtask`). + - This method offers better performance, structured data exchange, and richer error handling compared to CLI parsing. + - Refer to [`mcp.mdc`](mdc:.cursor/rules/mcp.mdc) for details on the MCP architecture and available tools. + - A comprehensive list and description of MCP tools and their corresponding CLI commands can be found in [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc). + - **Restart the MCP server** if core logic in `scripts/modules` or MCP tool/direct function definitions change. + +2. **`task-master` CLI (For Users & Fallback)**: + - The global `task-master` command provides a user-friendly interface for direct terminal interaction. + - It can also serve as a fallback if the MCP server is inaccessible or a specific function isn't exposed via MCP. + - Install globally with `npm install -g task-master-ai` or use locally via `npx task-master-ai ...`. + - The CLI commands often mirror the MCP tools (e.g., `task-master list` corresponds to `get_tasks`). + - Refer to [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc) for a detailed command reference. + +## Standard Development Workflow Process + +- Start new projects by running `initialize_project` tool / `task-master init` or `parse_prd` / `task-master parse-prd --input=''` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to generate initial tasks.json +- Begin coding sessions with `get_tasks` / `task-master list` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to see current tasks, status, and IDs +- Determine the next task to work on using `next_task` / `task-master next` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Analyze task complexity with `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before breaking down tasks +- Review complexity report using `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Select tasks based on dependencies (all marked 'done'), priority level, and ID order +- Clarify tasks by checking task files in tasks/ directory or asking for user input +- View specific task details using `get_task` / `task-master show ` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to understand implementation requirements +- Break down complex tasks using `expand_task` / `task-master expand --id= --force --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) with appropriate flags like `--force` (to replace existing subtasks) and `--research`. +- Clear existing subtasks if needed using `clear_subtasks` / `task-master clear-subtasks --id=` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) before regenerating +- Implement code following task details, dependencies, and project standards +- Verify tasks according to test strategies before marking as complete (See [`tests.mdc`](mdc:.cursor/rules/tests.mdc)) +- Mark completed tasks with `set_task_status` / `task-master set-status --id= --status=done` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) +- Update dependent tasks when implementation differs from original plan using `update` / `task-master update --from= --prompt="..."` or `update_task` / `task-master update-task --id= --prompt="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) +- Add new tasks discovered during implementation using `add_task` / `task-master add-task --prompt="..." --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Add new subtasks as needed using `add_subtask` / `task-master add-subtask --parent= --title="..."` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Append notes or details to subtasks using `update_subtask` / `task-master update-subtask --id= --prompt='Add implementation notes here...\nMore details...'` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)). +- Generate task files with `generate` / `task-master generate` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) after updating tasks.json +- Maintain valid dependency structure with `add_dependency`/`remove_dependency` tools or `task-master add-dependency`/`remove-dependency` commands, `validate_dependencies` / `task-master validate-dependencies`, and `fix_dependencies` / `task-master fix-dependencies` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) when needed +- Respect dependency chains and task priorities when selecting work +- Report progress regularly using `get_tasks` / `task-master list` + +## Task Complexity Analysis + +- Run `analyze_project_complexity` / `task-master analyze-complexity --research` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for comprehensive analysis +- Review complexity report via `complexity_report` / `task-master complexity-report` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) for a formatted, readable version. +- Focus on tasks with highest complexity scores (8-10) for detailed breakdown +- Use analysis results to determine appropriate subtask allocation +- Note that reports are automatically used by the `expand_task` tool/command + +## Task Breakdown Process + +- Use `expand_task` / `task-master expand --id=`. It automatically uses the complexity report if found, otherwise generates default number of subtasks. +- Use `--num=` to specify an explicit number of subtasks, overriding defaults or complexity report recommendations. +- Add `--research` flag to leverage Perplexity AI for research-backed expansion. +- Add `--force` flag to clear existing subtasks before generating new ones (default is to append). +- Use `--prompt=""` to provide additional context when needed. +- Review and adjust generated subtasks as necessary. +- Use `expand_all` tool or `task-master expand --all` to expand multiple pending tasks at once, respecting flags like `--force` and `--research`. +- If subtasks need complete replacement (regardless of the `--force` flag on `expand`), clear them first with `clear_subtasks` / `task-master clear-subtasks --id=`. + +## Implementation Drift Handling + +- When implementation differs significantly from planned approach +- When future tasks need modification due to current implementation choices +- When new dependencies or requirements emerge +- Use `update` / `task-master update --from= --prompt='\nUpdate context...' --research` to update multiple future tasks. +- Use `update_task` / `task-master update-task --id= --prompt='\nUpdate context...' --research` to update a single specific task. + +## Task Status Management + +- Use 'pending' for tasks ready to be worked on +- Use 'done' for completed and verified tasks +- Use 'deferred' for postponed tasks +- Add custom status values as needed for project-specific workflows + +## Task Structure Fields + +- **id**: Unique identifier for the task (Example: `1`, `1.1`) +- **title**: Brief, descriptive title (Example: `"Initialize Repo"`) +- **description**: Concise summary of what the task involves (Example: `"Create a new repository, set up initial structure."`) +- **status**: Current state of the task (Example: `"pending"`, `"done"`, `"deferred"`) +- **dependencies**: IDs of prerequisite tasks (Example: `[1, 2.1]`) + - Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending) + - This helps quickly identify which prerequisite tasks are blocking work +- **priority**: Importance level (Example: `"high"`, `"medium"`, `"low"`) +- **details**: In-depth implementation instructions (Example: `"Use GitHub client ID/secret, handle callback, set session token."`) +- **testStrategy**: Verification approach (Example: `"Deploy and call endpoint to confirm 'Hello World' response."`) +- **subtasks**: List of smaller, more specific tasks (Example: `[{"id": 1, "title": "Configure OAuth", ...}]`) +- Refer to task structure details (previously linked to `tasks.mdc`). + +## Configuration Management (Updated) + +Taskmaster configuration is managed through two main mechanisms: + +1. **`.taskmasterconfig` File (Primary):** + * Located in the project root directory. + * Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc. + * **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing. + * **View/Set specific models via `task-master models` command or `models` MCP tool.** + * Created automatically when you run `task-master models --setup` for the first time. + +2. **Environment Variables (`.env` / `mcp.json`):** + * Used **only** for sensitive API keys and specific endpoint URLs. + * Place API keys (one per provider) in a `.env` file in the project root for CLI usage. + * For MCP/Cursor integration, configure these keys in the `env` section of `.cursor/mcp.json`. + * Available keys/variables: See `assets/env.example` or the Configuration section in the command reference (previously linked to `taskmaster.mdc`). + +**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool. +**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the `env` section of `.cursor/mcp.json`. +**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the `.env` file in the root of the project. + +## Determining the Next Task + +- Run `next_task` / `task-master next` to show the next task to work on. +- The command identifies tasks with all dependencies satisfied +- Tasks are prioritized by priority level, dependency count, and ID +- The command shows comprehensive task information including: + - Basic task details and description + - Implementation details + - Subtasks (if they exist) + - Contextual suggested actions +- Recommended before starting any new development work +- Respects your project's dependency structure +- Ensures tasks are completed in the appropriate sequence +- Provides ready-to-use commands for common task actions + +## Viewing Specific Task Details + +- Run `get_task` / `task-master show ` to view a specific task. +- Use dot notation for subtasks: `task-master show 1.2` (shows subtask 2 of task 1) +- Displays comprehensive information similar to the next command, but for a specific task +- For parent tasks, shows all subtasks and their current status +- For subtasks, shows parent task information and relationship +- Provides contextual suggested actions appropriate for the specific task +- Useful for examining task details before implementation or checking status + +## Managing Task Dependencies + +- Use `add_dependency` / `task-master add-dependency --id= --depends-on=` to add a dependency. +- Use `remove_dependency` / `task-master remove-dependency --id= --depends-on=` to remove a dependency. +- The system prevents circular dependencies and duplicate dependency entries +- Dependencies are checked for existence before being added or removed +- Task files are automatically regenerated after dependency changes +- Dependencies are visualized with status indicators in task listings and files + +## Iterative Subtask Implementation + +Once a task has been broken down into subtasks using `expand_task` or similar methods, follow this iterative process for implementation: + +1. **Understand the Goal (Preparation):** + * Use `get_task` / `task-master show ` (see [`taskmaster.mdc`](mdc:.cursor/rules/taskmaster.mdc)) to thoroughly understand the specific goals and requirements of the subtask. + +2. **Initial Exploration & Planning (Iteration 1):** + * This is the first attempt at creating a concrete implementation plan. + * Explore the codebase to identify the precise files, functions, and even specific lines of code that will need modification. + * Determine the intended code changes (diffs) and their locations. + * Gather *all* relevant details from this exploration phase. + +3. **Log the Plan:** + * Run `update_subtask` / `task-master update-subtask --id= --prompt=''`. + * Provide the *complete and detailed* findings from the exploration phase in the prompt. Include file paths, line numbers, proposed diffs, reasoning, and any potential challenges identified. Do not omit details. The goal is to create a rich, timestamped log within the subtask's `details`. + +4. **Verify the Plan:** + * Run `get_task` / `task-master show ` again to confirm that the detailed implementation plan has been successfully appended to the subtask's details. + +5. **Begin Implementation:** + * Set the subtask status using `set_task_status` / `task-master set-status --id= --status=in-progress`. + * Start coding based on the logged plan. + +6. **Refine and Log Progress (Iteration 2+):** + * As implementation progresses, you will encounter challenges, discover nuances, or confirm successful approaches. + * **Before appending new information**: Briefly review the *existing* details logged in the subtask (using `get_task` or recalling from context) to ensure the update adds fresh insights and avoids redundancy. + * **Regularly** use `update_subtask` / `task-master update-subtask --id= --prompt='\n- What worked...\n- What didn't work...'` to append new findings. + * **Crucially, log:** + * What worked ("fundamental truths" discovered). + * What didn't work and why (to avoid repeating mistakes). + * Specific code snippets or configurations that were successful. + * Decisions made, especially if confirmed with user input. + * Any deviations from the initial plan and the reasoning. + * The objective is to continuously enrich the subtask's details, creating a log of the implementation journey that helps the AI (and human developers) learn, adapt, and avoid repeating errors. + +7. **Review & Update Rules (Post-Implementation):** + * Once the implementation for the subtask is functionally complete, review all code changes and the relevant chat history. + * Identify any new or modified code patterns, conventions, or best practices established during the implementation. + * Create new or update existing rules following internal guidelines (previously linked to `cursor_rules.mdc` and `self_improve.mdc`). + +8. **Mark Task Complete:** + * After verifying the implementation and updating any necessary rules, mark the subtask as completed: `set_task_status` / `task-master set-status --id= --status=done`. + +9. **Commit Changes (If using Git):** + * Stage the relevant code changes and any updated/new rule files (`git add .`). + * Craft a comprehensive Git commit message summarizing the work done for the subtask, including both code implementation and any rule adjustments. + * Execute the commit command directly in the terminal (e.g., `git commit -m 'feat(module): Implement feature X for subtask \n\n- Details about changes...\n- Updated rule Y for pattern Z'`). + * Consider if a Changeset is needed according to internal versioning guidelines (previously linked to `changeset.mdc`). If so, run `npm run changeset`, stage the generated file, and amend the commit or create a new one. + +10. **Proceed to Next Subtask:** + * Identify the next subtask (e.g., using `next_task` / `task-master next`). + +## Code Analysis & Refactoring Techniques + +- **Top-Level Function Search**: + - Useful for understanding module structure or planning refactors. + - Use grep/ripgrep to find exported functions/constants: + `rg "export (async function|function|const) \w+"` or similar patterns. + - Can help compare functions between files during migrations or identify potential naming conflicts. + +--- +*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.* \ No newline at end of file diff --git a/.cursor/rules/self_improve.mdc b/.cursor/rules/self_improve.mdc new file mode 100644 index 00000000..40b31b6e --- /dev/null +++ b/.cursor/rules/self_improve.mdc @@ -0,0 +1,72 @@ +--- +description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices. +globs: **/* +alwaysApply: true +--- + +- **Rule Improvement Triggers:** + - New code patterns not covered by existing rules + - Repeated similar implementations across files + - Common error patterns that could be prevented + - New libraries or tools being used consistently + - Emerging best practices in the codebase + +- **Analysis Process:** + - Compare new code with existing rules + - Identify patterns that should be standardized + - Look for references to external documentation + - Check for consistent error handling patterns + - Monitor test patterns and coverage + +- **Rule Updates:** + - **Add New Rules When:** + - A new technology/pattern is used in 3+ files + - Common bugs could be prevented by a rule + - Code reviews repeatedly mention the same feedback + - New security or performance patterns emerge + + - **Modify Existing Rules When:** + - Better examples exist in the codebase + - Additional edge cases are discovered + - Related rules have been updated + - Implementation details have changed + +- **Example Pattern Recognition:** + ```typescript + // If you see repeated patterns like: + const data = await prisma.user.findMany({ + select: { id: true, email: true }, + where: { status: 'ACTIVE' } + }); + + // Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc): + // - Standard select fields + // - Common where conditions + // - Performance optimization patterns + ``` + +- **Rule Quality Checks:** + - Rules should be actionable and specific + - Examples should come from actual code + - References should be up to date + - Patterns should be consistently enforced + +- **Continuous Improvement:** + - Monitor code review comments + - Track common development questions + - Update rules after major refactors + - Add links to relevant documentation + - Cross-reference related rules + +- **Rule Deprecation:** + - Mark outdated patterns as deprecated + - Remove rules that no longer apply + - Update references to deprecated rules + - Document migration paths for old patterns + +- **Documentation Updates:** + - Keep examples synchronized with code + - Update references to external docs + - Maintain links between related rules + - Document breaking changes +Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure. diff --git a/.cursor/rules/taskmaster.mdc b/.cursor/rules/taskmaster.mdc new file mode 100644 index 00000000..fd6a8384 --- /dev/null +++ b/.cursor/rules/taskmaster.mdc @@ -0,0 +1,382 @@ +--- +description: Comprehensive reference for Taskmaster MCP tools and CLI commands. +globs: **/* +alwaysApply: true +--- +# Taskmaster Tool & Command Reference + +This document provides a detailed reference for interacting with Taskmaster, covering both the recommended MCP tools, suitable for integrations like Cursor, and the corresponding `task-master` CLI commands, designed for direct user interaction or fallback. + +**Note:** For interacting with Taskmaster programmatically or via integrated tools, using the **MCP tools is strongly recommended** due to better performance, structured data, and error handling. The CLI commands serve as a user-friendly alternative and fallback. + +**Important:** Several MCP tools involve AI processing... The AI-powered tools include `parse_prd`, `analyze_project_complexity`, `update_subtask`, `update_task`, `update`, `expand_all`, `expand_task`, and `add_task`. + +--- + +## Initialization & Setup + +### 1. Initialize Project (`init`) + +* **MCP Tool:** `initialize_project` +* **CLI Command:** `task-master init [options]` +* **Description:** `Set up the basic Taskmaster file structure and configuration in the current directory for a new project.` +* **Key CLI Options:** + * `--name `: `Set the name for your project in Taskmaster's configuration.` + * `--description `: `Provide a brief description for your project.` + * `--version `: `Set the initial version for your project, e.g., '0.1.0'.` + * `-y, --yes`: `Initialize Taskmaster quickly using default settings without interactive prompts.` +* **Usage:** Run this once at the beginning of a new project. +* **MCP Variant Description:** `Set up the basic Taskmaster file structure and configuration in the current directory for a new project by running the 'task-master init' command.` +* **Key MCP Parameters/Options:** + * `projectName`: `Set the name for your project.` (CLI: `--name `) + * `projectDescription`: `Provide a brief description for your project.` (CLI: `--description `) + * `projectVersion`: `Set the initial version for your project, e.g., '0.1.0'.` (CLI: `--version `) + * `authorName`: `Author name.` (CLI: `--author `) + * `skipInstall`: `Skip installing dependencies. Default is false.` (CLI: `--skip-install`) + * `addAliases`: `Add shell aliases tm and taskmaster. Default is false.` (CLI: `--aliases`) + * `yes`: `Skip prompts and use defaults/provided arguments. Default is false.` (CLI: `-y, --yes`) +* **Usage:** Run this once at the beginning of a new project, typically via an integrated tool like Cursor. Operates on the current working directory of the MCP server. +* **Important:** Once complete, you *MUST* parse a prd in order to generate tasks. There will be no tasks files until then. The next step after initializing should be to create a PRD using the example PRD in scripts/example_prd.txt. + +### 2. Parse PRD (`parse_prd`) + +* **MCP Tool:** `parse_prd` +* **CLI Command:** `task-master parse-prd [file] [options]` +* **Description:** `Parse a Product Requirements Document, PRD, or text file with Taskmaster to automatically generate an initial set of tasks in tasks.json.` +* **Key Parameters/Options:** + * `input`: `Path to your PRD or requirements text file that Taskmaster should parse for tasks.` (CLI: `[file]` positional or `-i, --input `) + * `output`: `Specify where Taskmaster should save the generated 'tasks.json' file. Defaults to 'tasks/tasks.json'.` (CLI: `-o, --output `) + * `numTasks`: `Approximate number of top-level tasks Taskmaster should aim to generate from the document.` (CLI: `-n, --num-tasks `) + * `force`: `Use this to allow Taskmaster to overwrite an existing 'tasks.json' without asking for confirmation.` (CLI: `-f, --force`) +* **Usage:** Useful for bootstrapping a project from an existing requirements document. +* **Notes:** Task Master will strictly adhere to any specific requirements mentioned in the PRD, such as libraries, database schemas, frameworks, tech stacks, etc., while filling in any gaps where the PRD isn't fully specified. Tasks are designed to provide the most direct implementation path while avoiding over-engineering. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. If the user does not have a PRD, suggest discussing their idea and then use the example PRD in `scripts/example_prd.txt` as a template for creating the PRD based on their idea, for use with `parse-prd`. + +--- + +## AI Model Configuration + +### 2. Manage Models (`models`) +* **MCP Tool:** `models` +* **CLI Command:** `task-master models [options]` +* **Description:** `View the current AI model configuration or set specific models for different roles (main, research, fallback). Allows setting custom model IDs for Ollama and OpenRouter.` +* **Key MCP Parameters/Options:** + * `setMain `: `Set the primary model ID for task generation/updates.` (CLI: `--set-main `) + * `setResearch `: `Set the model ID for research-backed operations.` (CLI: `--set-research `) + * `setFallback `: `Set the model ID to use if the primary fails.` (CLI: `--set-fallback `) + * `ollama `: `Indicates the set model ID is a custom Ollama model.` (CLI: `--ollama`) + * `openrouter `: `Indicates the set model ID is a custom OpenRouter model.` (CLI: `--openrouter`) + * `listAvailableModels `: `If true, lists available models not currently assigned to a role.` (CLI: No direct equivalent; CLI lists available automatically) + * `projectRoot `: `Optional. Absolute path to the project root directory.` (CLI: Determined automatically) +* **Key CLI Options:** + * `--set-main `: `Set the primary model.` + * `--set-research `: `Set the research model.` + * `--set-fallback `: `Set the fallback model.` + * `--ollama`: `Specify that the provided model ID is for Ollama (use with --set-*).` + * `--openrouter`: `Specify that the provided model ID is for OpenRouter (use with --set-*). Validates against OpenRouter API.` + * `--setup`: `Run interactive setup to configure models, including custom Ollama/OpenRouter IDs.` +* **Usage (MCP):** Call without set flags to get current config. Use `setMain`, `setResearch`, or `setFallback` with a valid model ID to update the configuration. Use `listAvailableModels: true` to get a list of unassigned models. To set a custom model, provide the model ID and set `ollama: true` or `openrouter: true`. +* **Usage (CLI):** Run without flags to view current configuration and available models. Use set flags to update specific roles. Use `--setup` for guided configuration, including custom models. To set a custom model via flags, use `--set-=` along with either `--ollama` or `--openrouter`. +* **Notes:** Configuration is stored in `.taskmasterconfig` in the project root. This command/tool modifies that file. Use `listAvailableModels` or `task-master models` to see internally supported models. OpenRouter custom models are validated against their live API. Ollama custom models are not validated live. +* **API note:** API keys for selected AI providers (based on their model) need to exist in the mcp.json file to be accessible in MCP context. The API keys must be present in the local .env file for the CLI to be able to read them. +* **Model costs:** The costs in supported models are expressed in dollars. An input/output value of 3 is $3.00. A value of 0.8 is $0.80. +* **Warning:** DO NOT MANUALLY EDIT THE .taskmasterconfig FILE. Use the included commands either in the MCP or CLI format as needed. Always prioritize MCP tools when available and use the CLI as a fallback. + +--- + +## Task Listing & Viewing + +### 3. Get Tasks (`get_tasks`) + +* **MCP Tool:** `get_tasks` +* **CLI Command:** `task-master list [options]` +* **Description:** `List your Taskmaster tasks, optionally filtering by status and showing subtasks.` +* **Key Parameters/Options:** + * `status`: `Show only Taskmaster tasks matching this status, e.g., 'pending' or 'done'.` (CLI: `-s, --status `) + * `withSubtasks`: `Include subtasks indented under their parent tasks in the list.` (CLI: `--with-subtasks`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) +* **Usage:** Get an overview of the project status, often used at the start of a work session. + +### 4. Get Next Task (`next_task`) + +* **MCP Tool:** `next_task` +* **CLI Command:** `task-master next [options]` +* **Description:** `Ask Taskmaster to show the next available task you can work on, based on status and completed dependencies.` +* **Key Parameters/Options:** + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) +* **Usage:** Identify what to work on next according to the plan. + +### 5. Get Task Details (`get_task`) + +* **MCP Tool:** `get_task` +* **CLI Command:** `task-master show [id] [options]` +* **Description:** `Display detailed information for a specific Taskmaster task or subtask by its ID.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster task, e.g., '15', or subtask, e.g., '15.2', you want to view.` (CLI: `[id]` positional or `-i, --id `) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) +* **Usage:** Understand the full details, implementation notes, and test strategy for a specific task before starting work. + +--- + +## Task Creation & Modification + +### 6. Add Task (`add_task`) + +* **MCP Tool:** `add_task` +* **CLI Command:** `task-master add-task [options]` +* **Description:** `Add a new task to Taskmaster by describing it; AI will structure it.` +* **Key Parameters/Options:** + * `prompt`: `Required. Describe the new task you want Taskmaster to create, e.g., "Implement user authentication using JWT".` (CLI: `-p, --prompt `) + * `dependencies`: `Specify the IDs of any Taskmaster tasks that must be completed before this new one can start, e.g., '12,14'.` (CLI: `-d, --dependencies `) + * `priority`: `Set the priority for the new task: 'high', 'medium', or 'low'. Default is 'medium'.` (CLI: `--priority `) + * `research`: `Enable Taskmaster to use the research role for potentially more informed task creation.` (CLI: `-r, --research`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) +* **Usage:** Quickly add newly identified tasks during development. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 7. Add Subtask (`add_subtask`) + +* **MCP Tool:** `add_subtask` +* **CLI Command:** `task-master add-subtask [options]` +* **Description:** `Add a new subtask to a Taskmaster parent task, or convert an existing task into a subtask.` +* **Key Parameters/Options:** + * `id` / `parent`: `Required. The ID of the Taskmaster task that will be the parent.` (MCP: `id`, CLI: `-p, --parent `) + * `taskId`: `Use this if you want to convert an existing top-level Taskmaster task into a subtask of the specified parent.` (CLI: `-i, --task-id `) + * `title`: `Required if not using taskId. The title for the new subtask Taskmaster should create.` (CLI: `-t, --title `) + * `description`: `A brief description for the new subtask.` (CLI: `-d, --description <text>`) + * `details`: `Provide implementation notes or details for the new subtask.` (CLI: `--details <text>`) + * `dependencies`: `Specify IDs of other tasks or subtasks, e.g., '15' or '16.1', that must be done before this new subtask.` (CLI: `--dependencies <ids>`) + * `status`: `Set the initial status for the new subtask. Default is 'pending'.` (CLI: `-s, --status <status>`) + * `skipGenerate`: `Prevent Taskmaster from automatically regenerating markdown task files after adding the subtask.` (CLI: `--skip-generate`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Break down tasks manually or reorganize existing tasks. + +### 8. Update Tasks (`update`) + +* **MCP Tool:** `update` +* **CLI Command:** `task-master update [options]` +* **Description:** `Update multiple upcoming tasks in Taskmaster based on new context or changes, starting from a specific task ID.` +* **Key Parameters/Options:** + * `from`: `Required. The ID of the first task Taskmaster should update. All tasks with this ID or higher that are not 'done' will be considered.` (CLI: `--from <id>`) + * `prompt`: `Required. Explain the change or new context for Taskmaster to apply to the tasks, e.g., "We are now using React Query instead of Redux Toolkit for data fetching".` (CLI: `-p, --prompt <text>`) + * `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Handle significant implementation changes or pivots that affect multiple future tasks. Example CLI: `task-master update --from='18' --prompt='Switching to React Query.\nNeed to refactor data fetching...'` +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 9. Update Task (`update_task`) + +* **MCP Tool:** `update_task` +* **CLI Command:** `task-master update-task [options]` +* **Description:** `Modify a specific Taskmaster task or subtask by its ID, incorporating new information or changes.` +* **Key Parameters/Options:** + * `id`: `Required. The specific ID of the Taskmaster task, e.g., '15', or subtask, e.g., '15.2', you want to update.` (CLI: `-i, --id <id>`) + * `prompt`: `Required. Explain the specific changes or provide the new information Taskmaster should incorporate into this task.` (CLI: `-p, --prompt <text>`) + * `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Refine a specific task based on new understanding or feedback. Example CLI: `task-master update-task --id='15' --prompt='Clarification: Use PostgreSQL instead of MySQL.\nUpdate schema details...'` +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 10. Update Subtask (`update_subtask`) + +* **MCP Tool:** `update_subtask` +* **CLI Command:** `task-master update-subtask [options]` +* **Description:** `Append timestamped notes or details to a specific Taskmaster subtask without overwriting existing content. Intended for iterative implementation logging.` +* **Key Parameters/Options:** + * `id`: `Required. The specific ID of the Taskmaster subtask, e.g., '15.2', you want to add information to.` (CLI: `-i, --id <id>`) + * `prompt`: `Required. Provide the information or notes Taskmaster should append to the subtask's details. Ensure this adds *new* information not already present.` (CLI: `-p, --prompt <text>`) + * `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Add implementation notes, code snippets, or clarifications to a subtask during development. Before calling, review the subtask's current details to append only fresh insights, helping to build a detailed log of the implementation journey and avoid redundancy. Example CLI: `task-master update-subtask --id='15.2' --prompt='Discovered that the API requires header X.\nImplementation needs adjustment...'` +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 11. Set Task Status (`set_task_status`) + +* **MCP Tool:** `set_task_status` +* **CLI Command:** `task-master set-status [options]` +* **Description:** `Update the status of one or more Taskmaster tasks or subtasks, e.g., 'pending', 'in-progress', 'done'.` +* **Key Parameters/Options:** + * `id`: `Required. The ID(s) of the Taskmaster task(s) or subtask(s), e.g., '15', '15.2', or '16,17.1', to update.` (CLI: `-i, --id <id>`) + * `status`: `Required. The new status to set, e.g., 'done', 'pending', 'in-progress', 'review', 'cancelled'.` (CLI: `-s, --status <status>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Mark progress as tasks move through the development cycle. + +### 12. Remove Task (`remove_task`) + +* **MCP Tool:** `remove_task` +* **CLI Command:** `task-master remove-task [options]` +* **Description:** `Permanently remove a task or subtask from the Taskmaster tasks list.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster task, e.g., '5', or subtask, e.g., '5.2', to permanently remove.` (CLI: `-i, --id <id>`) + * `yes`: `Skip the confirmation prompt and immediately delete the task.` (CLI: `-y, --yes`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Permanently delete tasks or subtasks that are no longer needed in the project. +* **Notes:** Use with caution as this operation cannot be undone. Consider using 'blocked', 'cancelled', or 'deferred' status instead if you just want to exclude a task from active planning but keep it for reference. The command automatically cleans up dependency references in other tasks. + +--- + +## Task Structure & Breakdown + +### 13. Expand Task (`expand_task`) + +* **MCP Tool:** `expand_task` +* **CLI Command:** `task-master expand [options]` +* **Description:** `Use Taskmaster's AI to break down a complex task into smaller, manageable subtasks. Appends subtasks by default.` +* **Key Parameters/Options:** + * `id`: `The ID of the specific Taskmaster task you want to break down into subtasks.` (CLI: `-i, --id <id>`) + * `num`: `Optional: Suggests how many subtasks Taskmaster should aim to create. Uses complexity analysis/defaults otherwise.` (CLI: `-n, --num <number>`) + * `research`: `Enable Taskmaster to use the research role for more informed subtask generation. Requires appropriate API key.` (CLI: `-r, --research`) + * `prompt`: `Optional: Provide extra context or specific instructions to Taskmaster for generating the subtasks.` (CLI: `-p, --prompt <text>`) + * `force`: `Optional: If true, clear existing subtasks before generating new ones. Default is false (append).` (CLI: `--force`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Generate a detailed implementation plan for a complex task before starting coding. Automatically uses complexity report recommendations if available and `num` is not specified. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 14. Expand All Tasks (`expand_all`) + +* **MCP Tool:** `expand_all` +* **CLI Command:** `task-master expand --all [options]` (Note: CLI uses the `expand` command with the `--all` flag) +* **Description:** `Tell Taskmaster to automatically expand all eligible pending/in-progress tasks based on complexity analysis or defaults. Appends subtasks by default.` +* **Key Parameters/Options:** + * `num`: `Optional: Suggests how many subtasks Taskmaster should aim to create per task.` (CLI: `-n, --num <number>`) + * `research`: `Enable research role for more informed subtask generation. Requires appropriate API key.` (CLI: `-r, --research`) + * `prompt`: `Optional: Provide extra context for Taskmaster to apply generally during expansion.` (CLI: `-p, --prompt <text>`) + * `force`: `Optional: If true, clear existing subtasks before generating new ones for each eligible task. Default is false (append).` (CLI: `--force`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Useful after initial task generation or complexity analysis to break down multiple tasks at once. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 15. Clear Subtasks (`clear_subtasks`) + +* **MCP Tool:** `clear_subtasks` +* **CLI Command:** `task-master clear-subtasks [options]` +* **Description:** `Remove all subtasks from one or more specified Taskmaster parent tasks.` +* **Key Parameters/Options:** + * `id`: `The ID(s) of the Taskmaster parent task(s) whose subtasks you want to remove, e.g., '15' or '16,18'. Required unless using `all`.) (CLI: `-i, --id <ids>`) + * `all`: `Tell Taskmaster to remove subtasks from all parent tasks.` (CLI: `--all`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Used before regenerating subtasks with `expand_task` if the previous breakdown needs replacement. + +### 16. Remove Subtask (`remove_subtask`) + +* **MCP Tool:** `remove_subtask` +* **CLI Command:** `task-master remove-subtask [options]` +* **Description:** `Remove a subtask from its Taskmaster parent, optionally converting it into a standalone task.` +* **Key Parameters/Options:** + * `id`: `Required. The ID(s) of the Taskmaster subtask(s) to remove, e.g., '15.2' or '16.1,16.3'.` (CLI: `-i, --id <id>`) + * `convert`: `If used, Taskmaster will turn the subtask into a regular top-level task instead of deleting it.` (CLI: `-c, --convert`) + * `skipGenerate`: `Prevent Taskmaster from automatically regenerating markdown task files after removing the subtask.` (CLI: `--skip-generate`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Delete unnecessary subtasks or promote a subtask to a top-level task. + +--- + +## Dependency Management + +### 17. Add Dependency (`add_dependency`) + +* **MCP Tool:** `add_dependency` +* **CLI Command:** `task-master add-dependency [options]` +* **Description:** `Define a dependency in Taskmaster, making one task a prerequisite for another.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster task that will depend on another.` (CLI: `-i, --id <id>`) + * `dependsOn`: `Required. The ID of the Taskmaster task that must be completed first, the prerequisite.` (CLI: `-d, --depends-on <id>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <path>`) +* **Usage:** Establish the correct order of execution between tasks. + +### 18. Remove Dependency (`remove_dependency`) + +* **MCP Tool:** `remove_dependency` +* **CLI Command:** `task-master remove-dependency [options]` +* **Description:** `Remove a dependency relationship between two Taskmaster tasks.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster task you want to remove a prerequisite from.` (CLI: `-i, --id <id>`) + * `dependsOn`: `Required. The ID of the Taskmaster task that should no longer be a prerequisite.` (CLI: `-d, --depends-on <id>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Update task relationships when the order of execution changes. + +### 19. Validate Dependencies (`validate_dependencies`) + +* **MCP Tool:** `validate_dependencies` +* **CLI Command:** `task-master validate-dependencies [options]` +* **Description:** `Check your Taskmaster tasks for dependency issues (like circular references or links to non-existent tasks) without making changes.` +* **Key Parameters/Options:** + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Audit the integrity of your task dependencies. + +### 20. Fix Dependencies (`fix_dependencies`) + +* **MCP Tool:** `fix_dependencies` +* **CLI Command:** `task-master fix-dependencies [options]` +* **Description:** `Automatically fix dependency issues (like circular references or links to non-existent tasks) in your Taskmaster tasks.` +* **Key Parameters/Options:** + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Clean up dependency errors automatically. + +--- + +## Analysis & Reporting + +### 21. Analyze Project Complexity (`analyze_project_complexity`) + +* **MCP Tool:** `analyze_project_complexity` +* **CLI Command:** `task-master analyze-complexity [options]` +* **Description:** `Have Taskmaster analyze your tasks to determine their complexity and suggest which ones need to be broken down further.` +* **Key Parameters/Options:** + * `output`: `Where to save the complexity analysis report (default: 'scripts/task-complexity-report.json').` (CLI: `-o, --output <file>`) + * `threshold`: `The minimum complexity score (1-10) that should trigger a recommendation to expand a task.` (CLI: `-t, --threshold <number>`) + * `research`: `Enable research role for more accurate complexity analysis. Requires appropriate API key.` (CLI: `-r, --research`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Used before breaking down tasks to identify which ones need the most attention. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 22. View Complexity Report (`complexity_report`) + +* **MCP Tool:** `complexity_report` +* **CLI Command:** `task-master complexity-report [options]` +* **Description:** `Display the task complexity analysis report in a readable format.` +* **Key Parameters/Options:** + * `file`: `Path to the complexity report (default: 'scripts/task-complexity-report.json').` (CLI: `-f, --file <file>`) +* **Usage:** Review and understand the complexity analysis results after running analyze-complexity. + +--- + +## File Management + +### 23. Generate Task Files (`generate`) + +* **MCP Tool:** `generate` +* **CLI Command:** `task-master generate [options]` +* **Description:** `Create or update individual Markdown files for each task based on your tasks.json.` +* **Key Parameters/Options:** + * `output`: `The directory where Taskmaster should save the task files (default: in a 'tasks' directory).` (CLI: `-o, --output <directory>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Run this after making changes to tasks.json to keep individual task files up to date. + +--- + +## Environment Variables Configuration (Updated) + +Taskmaster primarily uses the **`.taskmasterconfig`** file (in project root) for configuration (models, parameters, logging level, etc.), managed via `task-master models --setup`. + +Environment variables are used **only** for sensitive API keys related to AI providers and specific overrides like the Ollama base URL: + +* **API Keys (Required for corresponding provider):** + * `ANTHROPIC_API_KEY` + * `PERPLEXITY_API_KEY` + * `OPENAI_API_KEY` + * `GOOGLE_API_KEY` + * `MISTRAL_API_KEY` + * `AZURE_OPENAI_API_KEY` (Requires `AZURE_OPENAI_ENDPOINT` too) + * `OPENROUTER_API_KEY` + * `XAI_API_KEY` + * `OLLANA_API_KEY` (Requires `OLLAMA_BASE_URL` too) +* **Endpoints (Optional/Provider Specific inside .taskmasterconfig):** + * `AZURE_OPENAI_ENDPOINT` + * `OLLAMA_BASE_URL` (Default: `http://localhost:11434/api`) + +**Set API keys** in your **`.env`** file in the project root (for CLI use) or within the `env` section of your **`.cursor/mcp.json`** file (for MCP/Cursor integration). All other settings (model choice, max tokens, temperature, log level, custom endpoints) are managed in `.taskmasterconfig` via `task-master models` command or `models` MCP tool. + +--- + +For details on how these commands fit into the development process, see the [Development Workflow Guide](mdc:.cursor/rules/dev_workflow.mdc). diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d44c6b09 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# API Keys (Required to enable respective provider) +ANTHROPIC_API_KEY=your_anthropic_api_key_here # Required: Format: sk-ant-api03-... +PERPLEXITY_API_KEY=your_perplexity_api_key_here # Optional: Format: pplx-... +OPENAI_API_KEY=your_openai_api_key_here # Optional, for OpenAI/OpenRouter models. Format: sk-proj-... +GOOGLE_API_KEY=your_google_api_key_here # Optional, for Google Gemini models. +MISTRAL_API_KEY=your_mistral_key_here # Optional, for Mistral AI models. +XAI_API_KEY=YOUR_XAI_KEY_HERE # Optional, for xAI AI models. +AZURE_OPENAI_API_KEY=your_azure_key_here # Optional, for Azure OpenAI models (requires endpoint in .taskmasterconfig). \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5f3020a1..8249715a 100644 --- a/.gitignore +++ b/.gitignore @@ -182,4 +182,21 @@ dist storybook-static # React Router v7 -.react-router/ \ No newline at end of file +.react-router/ + +# Added by Claude Task Master +*.log +npm-debug.log* +dev-debug.log +# Environment variables +# Editor directories and files +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# OS specific +# Task files +tasks.json +tasks/ \ No newline at end of file diff --git a/.taskmasterconfig b/.taskmasterconfig new file mode 100644 index 00000000..0b874da5 --- /dev/null +++ b/.taskmasterconfig @@ -0,0 +1,31 @@ +{ + "models": { + "main": { + "provider": "anthropic", + "modelId": "claude-3-7-sonnet-20250219", + "maxTokens": 120000, + "temperature": 0.2 + }, + "research": { + "provider": "perplexity", + "modelId": "sonar-pro", + "maxTokens": 8700, + "temperature": 0.1 + }, + "fallback": { + "provider": "anthropic", + "modelId": "claude-3.5-sonnet-20240620", + "maxTokens": 120000, + "temperature": 0.1 + } + }, + "global": { + "logLevel": "info", + "debug": false, + "defaultSubtasks": 5, + "defaultPriority": "medium", + "projectName": "Taskmaster", + "ollamaBaseUrl": "http://localhost:11434/api", + "azureOpenaiBaseUrl": "https://your-endpoint.openai.azure.com/" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index f8b9823a..cd565466 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "autodocs", + "Bazza", "biomejs", "cleanbuild", "Filenaming", @@ -22,5 +23,6 @@ "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" }, - "tailwindCSS.classAttributes": ["class", "className", "ngClass", "class:list", "wrapperClassName"] + "tailwindCSS.classAttributes": ["class", "className", "ngClass", "class:list", "wrapperClassName"], + "cursor.general.disableHttp2": true } diff --git a/apps/docs/src/lib/storybook/react-router-stub.tsx b/apps/docs/src/lib/storybook/react-router-stub.tsx index 901b3f02..2e76472a 100644 --- a/apps/docs/src/lib/storybook/react-router-stub.tsx +++ b/apps/docs/src/lib/storybook/react-router-stub.tsx @@ -1,5 +1,9 @@ +import { Command } from '@lambdacurry/forms/ui'; import type { Decorator } from '@storybook/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'; import type { ComponentType } from 'react'; +import { DayPickerProvider } from 'react-day-picker'; import { type ActionFunction, type IndexRouteObject, @@ -40,6 +44,9 @@ interface RemixStubOptions { initialPath?: string; } +// Create a single QueryClient instance outside the decorator +const queryClient = new QueryClient(); + export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorator => { const { routes, initialPath = '/' } = options; // This outer function runs once when Storybook loads the story meta @@ -53,13 +60,11 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat // Get the base path (without existing query params from options) const basePath = initialPath.split('?')[0]; - + // Get the current search string from the actual browser window, if available // If not available, use a default search string with parameters needed for the data table - const currentWindowSearch = typeof window !== 'undefined' - ? window.location.search - : '?page=0&pageSize=10'; - + const currentWindowSearch = typeof window !== 'undefined' ? window.location.search : '?page=0&pageSize=10'; + // Combine them for the initial entry const actualInitialPath = `${basePath}${currentWindowSearch}`; @@ -69,7 +74,18 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat initialEntries: [actualInitialPath], // Use the path combined with window.location.search }); - return <RouterProvider router={router} />; + // Wrap existing providers with QueryClientProvider and DayPickerProvider + return ( + <QueryClientProvider client={queryClient}> + <DayPickerProvider initialProps={{}}> + <NuqsAdapter> + <Command> + <RouterProvider router={router} /> + </Command> + </NuqsAdapter> + </DayPickerProvider> + </QueryClientProvider> + ); }; }; diff --git a/apps/docs/src/remix-hook-form/data-table-bazza-filters.stories.tsx b/apps/docs/src/remix-hook-form/data-table-bazza-filters.stories.tsx new file mode 100644 index 00000000..d57b701f --- /dev/null +++ b/apps/docs/src/remix-hook-form/data-table-bazza-filters.stories.tsx @@ -0,0 +1,686 @@ +// --- NEW IMPORTS for Router Form data handling --- +import { dataTableRouterParsers } from '@lambdacurry/forms/remix-hook-form/data-table-router-parsers'; // Use parsers +// --- Corrected Hook Import Paths --- +import { DataTableFilter } from '@lambdacurry/forms/ui/data-table-filter'; // Use the barrel file export +// --- NEW IMPORTS for Bazza UI Filters --- +import { createColumnConfigHelper } from '@lambdacurry/forms/ui/data-table-filter/core/filters'; // Assuming path +import { DataTable } from '@lambdacurry/forms/ui/data-table/data-table'; +import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header'; +// Import the filters schema and types from the new location +import type { FiltersState } from '@lambdacurry/forms/ui/utils/filters'; // Assuming path alias +import { filtersArraySchema } from '@lambdacurry/forms/ui/utils/filters'; // Assuming path alias +// --- Re-add useDataTableFilters import --- +import { useDataTableFilters } from '@lambdacurry/forms/ui/utils/use-data-table-filters'; +import { useFilterSync } from '@lambdacurry/forms/ui/utils/use-filter-sync'; // Ensure this is the correct path for filter sync +// Add icon imports +import { CalendarIcon, CheckCircledIcon, PersonIcon, StarIcon, TextIcon } from '@radix-ui/react-icons'; +import type { Meta, StoryContext, StoryObj } from '@storybook/react'; // FIX: Add Meta, StoryObj, StoryContext +import type { ColumnDef, PaginationState, SortingState } from '@tanstack/react-table'; // Added PaginationState, SortingState +import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'; +import { useEffect, useMemo, useState } from 'react'; // Added useState, useEffect +import { type LoaderFunctionArgs, useLoaderData, useLocation, useNavigate } from 'react-router'; // Added LoaderFunctionArgs, useLoaderData, useNavigate, useLocation +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; // FIX: Add withReactRouterStubDecorator +import { expect, userEvent, within } from '@storybook/test'; // Add storybook test imports + +// --- Use MockIssue Schema and Data --- +interface MockIssue { + id: string; + title: string; + status: 'todo' | 'in progress' | 'done' | 'backlog'; + assignee: string; + priority: 'low' | 'medium' | 'high'; + createdDate: Date; +} + +// --- NEW Data Response Interface --- +interface DataResponse { + data: MockIssue[]; + meta: { + total: number; + page: number; + pageSize: number; + pageCount: number; + }; + facetedCounts: Record<string, Record<string, number>>; // Include faceted counts here +} +// --- END Data Response Interface --- + +// --- Mock Database (copied from deleted API route) --- +const mockDatabase: MockIssue[] = [ + { + id: 'TASK-1', + title: 'Fix login bug', + status: 'todo', + assignee: 'Alice', + priority: 'high', + createdDate: new Date('2024-01-15'), + }, + { + id: 'TASK-2', + title: 'Add dark mode', + status: 'in progress', + assignee: 'Bob', + priority: 'medium', + createdDate: new Date('2024-01-20'), + }, + { + id: 'TASK-3', + title: 'Improve dashboard performance', + status: 'in progress', + assignee: 'Alice', + priority: 'high', + createdDate: new Date('2024-02-01'), + }, + { + id: 'TASK-4', + title: 'Update documentation', + status: 'done', + assignee: 'Charlie', + priority: 'low', + createdDate: new Date('2024-02-10'), + }, + { + id: 'TASK-5', + title: 'Refactor auth module', + status: 'backlog', + assignee: 'Bob', + priority: 'medium', + createdDate: new Date('2024-02-15'), + }, + { + id: 'TASK-6', + title: 'Implement user profile page', + status: 'todo', + assignee: 'Charlie', + priority: 'medium', + createdDate: new Date('2024-03-01'), + }, + { + id: 'TASK-7', + title: 'Design new landing page', + status: 'todo', + assignee: 'Alice', + priority: 'high', + createdDate: new Date('2024-03-05'), + }, + { + id: 'TASK-8', + title: 'Write API integration tests', + status: 'in progress', + assignee: 'Bob', + priority: 'medium', + createdDate: new Date('2024-03-10'), + }, + { + id: 'TASK-9', + title: 'Deploy to staging environment', + status: 'todo', + assignee: 'Charlie', + priority: 'high', + createdDate: new Date('2024-03-15'), + }, + { + id: 'TASK-10', + title: 'User feedback session', + status: 'done', + assignee: 'Alice', + priority: 'low', + createdDate: new Date('2024-03-20'), + }, + { + id: 'TASK-11', + title: 'Fix critical bug in payment module', + status: 'in progress', + assignee: 'Bob', + priority: 'high', + createdDate: new Date('2024-03-22'), + }, + { + id: 'TASK-12', + title: 'Update third-party libraries', + status: 'backlog', + assignee: 'Charlie', + priority: 'low', + createdDate: new Date('2024-03-25'), + }, + { + id: 'TASK-13', + title: 'Onboard new developer', + status: 'done', + assignee: 'Alice', + priority: 'medium', + createdDate: new Date('2024-04-01'), + }, + { + id: 'TASK-14', + title: 'Research new caching strategy', + status: 'todo', + assignee: 'Bob', + priority: 'medium', + createdDate: new Date('2024-04-05'), + }, + { + id: 'TASK-15', + title: 'Accessibility audit', + status: 'in progress', + assignee: 'Charlie', + priority: 'high', + createdDate: new Date('2024-04-10'), + }, + // --- END ADDED DATA --- +]; + +// Function to calculate faceted counts based on the *original* data +// --- FIX: Ensure all defined options have counts (even 0) --- +function calculateFacetedCounts( + data: MockIssue[], + countColumns: Array<keyof MockIssue>, // Expect specific keys + allOptions: Record<keyof MockIssue, { value: string; label: string }[] | undefined>, // Pass defined options +): Record<string, Record<string, number>> { + const counts: Record<string, Record<string, number>> = {}; + + countColumns.forEach((columnId) => { + counts[columnId] = {}; + // Initialize counts for all defined options for this column to 0 + const definedOptions = allOptions[columnId]; + if (definedOptions) { + definedOptions.forEach((option) => { + counts[columnId][option.value] = 0; + }); + } + + // Count occurrences from the actual data + data.forEach((item) => { + const value = item[columnId] as string; + // Ensure value exists before incrementing (might be null/undefined) + if (value !== null && value !== undefined) { + counts[columnId][value] = (counts[columnId][value] || 0) + 1; + } + }); + }); + return counts; +} +// --- End Helper Functions --- + +// --- Define Columns with Bazza UI DSL (Task 4) --- +// Explicitly type the helper +const dtf = createColumnConfigHelper<MockIssue>(); + +// 1. Bazza UI Filter Configurations +const columnConfigs = [ + // Use accessor functions instead of strings + dtf + .text() + .id('title') + .accessor((row) => row.title) + .displayName('Title') + .icon(TextIcon) + .build(), + dtf + .option() + .id('status') + .accessor((row) => row.status) // Use accessor function + .displayName('Status') + .icon(CheckCircledIcon) + .options([ + { value: 'todo', label: 'Todo' }, + { value: 'in progress', label: 'In Progress' }, + { value: 'done', label: 'Done' }, + { value: 'backlog', label: 'Backlog' }, + ]) + .build(), + dtf + .option() + .id('assignee') + .accessor((row) => row.assignee) // Use accessor function + .displayName('Assignee') + .icon(PersonIcon) + .options([ + { value: 'Alice', label: 'Alice' }, + { value: 'Bob', label: 'Bob' }, + { value: 'Charlie', label: 'Charlie' }, + ]) + .build(), + dtf + .option() + .id('priority') + .accessor((row) => row.priority) // Use accessor function + .displayName('Priority') + .icon(StarIcon) + .options([ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + ]) + .build(), + dtf + .date() + .id('createdDate') + .accessor((row) => row.createdDate) + .displayName('Created Date') + .icon(CalendarIcon) + .build(), // Use accessor function +]; + +// --- FIX: Extract defined options for faceted counting --- +const allDefinedOptions: Record<keyof MockIssue, { value: string; label: string }[] | undefined> = { + id: undefined, + title: undefined, + status: columnConfigs.find((c) => c.id === 'status')?.options, + assignee: columnConfigs.find((c) => c.id === 'assignee')?.options, + priority: columnConfigs.find((c) => c.id === 'priority')?.options, + createdDate: undefined, +}; + +// 2. TanStack Table Column Definitions (for rendering) +const columns: ColumnDef<MockIssue>[] = [ + { + accessorKey: 'id', + header: ({ column }) => <DataTableColumnHeader column={column} title="ID" />, + cell: ({ row }) => <div className="font-medium">{row.getValue('id')}</div>, + enableSorting: false, + }, + { + accessorKey: 'title', + header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />, + cell: ({ row }) => <div>{row.getValue('title')}</div>, + }, + { + accessorKey: 'status', + header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />, + cell: ({ row }) => <div className="capitalize">{row.getValue('status')}</div>, + }, + { + accessorKey: 'assignee', + header: ({ column }) => <DataTableColumnHeader column={column} title="Assignee" />, + cell: ({ row }) => <div>{row.getValue('assignee')}</div>, + }, + { + accessorKey: 'priority', + header: ({ column }) => <DataTableColumnHeader column={column} title="Priority" />, + cell: ({ row }) => <div className="capitalize">{row.getValue('priority')}</div>, + }, + { + accessorKey: 'createdDate', + header: ({ column }) => <DataTableColumnHeader column={column} title="Created At" />, + cell: ({ row }) => <div>{new Date(row.getValue('createdDate')).toLocaleDateString()}</div>, + enableSorting: true, // Enable sorting for date + }, +]; +// --- END Column Definitions --- + +// --- NEW Wrapper Component using Loader Data --- +function DataTableWithBazzaFilters() { + const loaderData = useLoaderData<DataResponse>(); + const location = useLocation(); + const navigate = useNavigate(); + + // Ensure we have data even if loaderData is undefined + const data = loaderData?.data ?? []; + const pageCount = loaderData?.meta.pageCount ?? 0; + const facetedCounts = loaderData?.facetedCounts ?? {}; + + // Default pagination values + const defaultPageIndex = 0; + const defaultPageSize = 10; + + // Use useFilterSync to synchronize filters with URL + const [filters, setFilters] = useFilterSync(); + + // Local state for pagination and sorting + const [pagination, setPagination] = useState<PaginationState>({ + pageIndex: loaderData?.meta.page ?? defaultPageIndex, + pageSize: loaderData?.meta.pageSize ?? defaultPageSize, + }); + + // Extract sorting from URL + const [sorting, setSorting] = useState<SortingState>(() => { + const params = new URLSearchParams(location.search); + const sortField = params.get('sortField'); + const sortDesc = params.get('sortDesc') === 'true'; + return sortField ? [{ id: sortField, desc: sortDesc }] : []; + }); + + // Effect to synchronize pagination and sorting state FROM URL/loaderData if it changes + useEffect(() => { + const newPageIndex = loaderData?.meta.page ?? defaultPageIndex; + const newPageSize = loaderData?.meta.pageSize ?? defaultPageSize; + + if (pagination.pageIndex !== newPageIndex || pagination.pageSize !== newPageSize) { + setPagination({ pageIndex: newPageIndex, pageSize: newPageSize }); + } + + const params = new URLSearchParams(location.search); + const sortFieldFromUrl = params.get('sortField'); + const sortDescFromUrl = params.get('sortDesc') === 'true'; + + const currentSorting = sorting.length > 0 ? sorting[0] : null; + const urlHasSorting = !!sortFieldFromUrl; + + if (urlHasSorting) { + // Ensure sortFieldFromUrl is not null before using it with ! + if ( + sortFieldFromUrl && + (!currentSorting || currentSorting.id !== sortFieldFromUrl || currentSorting.desc !== sortDescFromUrl) + ) { + setSorting([{ id: sortFieldFromUrl, desc: sortDescFromUrl }]); + } + } else if (currentSorting) { + setSorting([]); + } + }, [loaderData, location.search, pagination, sorting, defaultPageIndex, defaultPageSize]); + + // Handlers for pagination and sorting changes that navigate + const handlePaginationChange = (updater: ((prevState: PaginationState) => PaginationState) | PaginationState) => { + const newState = typeof updater === 'function' ? updater(pagination) : updater; + const params = new URLSearchParams(location.search); // Preserve existing params like filters + params.set('page', String(newState.pageIndex)); + params.set('pageSize', String(newState.pageSize)); + // Sorting is not changed by pagination, so it's already in location.search or not + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); + }; + + const handleSortingChange = (updater: ((prevState: SortingState) => SortingState) | SortingState) => { + const newState = typeof updater === 'function' ? updater(sorting) : updater; + const params = new URLSearchParams(location.search); // Preserve existing params + + if (newState.length > 0) { + params.set('sortField', newState[0].id); + params.set('sortDesc', String(newState[0].desc)); + } else { + params.delete('sortField'); + params.delete('sortDesc'); + } + // Optionally reset page to 0 on sort change + // params.set('page', '0'); + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); + }; + + // Use Bazza UI hook (strategy: 'server' means it expects externally filtered/faceted data) + const { + columns: bazzaProcessedColumns, // These columns have filter components integrated + actions, + strategy, + } = useDataTableFilters<MockIssue, typeof columnConfigs, 'server'>({ + strategy: 'server', + columnsConfig: columnConfigs, // Pass the configurations + data: data, // Pass the data from the loader + faceted: facetedCounts, // Pass faceted counts from loader + filters: filters, // Use filters directly from useFilterSync + onFiltersChange: setFilters, // Use the setFilters function from useFilterSync + }); + + // Setup TanStack Table instance + const table = useReactTable({ + data, + columns: columns, // <-- Use original columns for cell rendering + state: { + pagination, // Controlled by local state, which is synced from URL + sorting, // Controlled by local state, which is synced from URL + filters, // Controlled by useFilterSync hook + }, + pageCount: pageCount, // Total pages from loader meta + onPaginationChange: handlePaginationChange, // Use new handler + onSortingChange: handleSortingChange, // Use new handler + manualPagination: true, // Pagination is handled by the loader + manualFiltering: true, // Filtering is handled by the loader (triggered by filters state) + manualSorting: true, // Sorting is handled by the loader + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), // Keep for potential features + getPaginationRowModel: getPaginationRowModel(), // Keep for potential features + }); + + return ( + <div className="container mx-auto py-10"> + <h1 className="text-2xl font-bold mb-4">Issues Table (Bazza UI Server Filters via Loader)</h1> + <p className="mb-4">This example demonstrates server-driven filtering using Bazza UI and React Router Loader:</p> + <ul className="list-disc pl-5 mb-4"> + <li>Filter state managed by Bazza UI filters component and synced to URL.</li> + <li>Pagination and sorting state managed locally, synced to URL via `useEffect`.</li> + <li>Data fetched via `loader` based on URL parameters (filters, pagination, sorting).</li> + <li>Server provides filtered/paginated/sorted data and faceted counts.</li> + </ul> + + {/* Render Bazza UI Filters - Pass Bazza's processed columns */} + <DataTableFilter columns={bazzaProcessedColumns} filters={filters} actions={actions} strategy={strategy} /> + {/* Pass table instance (which now uses original columns for rendering) */} + <DataTable className="mt-4" table={table} columns={columns.length} pagination /> + </div> + ); +} +// --- END Wrapper Component --- + +// Updated Loader function to return fake data matching DataResponse structure +const handleDataFetch = async ({ request }: LoaderFunctionArgs): Promise<DataResponse> => { + await new Promise((resolve) => setTimeout(resolve, 300)); // Simulate latency + + const url = new URL(request.url); + const params = url.searchParams; + + // Parse pagination, sorting, and filters from URL using helpers/schemas + const page = dataTableRouterParsers.page.parse(params.get('page')); + let pageSize = dataTableRouterParsers.pageSize.parse(params.get('pageSize')); + const sortField = params.get('sortField'); // Get raw string or null + const sortDesc = params.get('sortDesc') === 'true'; // Convert to boolean + const filtersParam = params.get('filters'); + + if (!pageSize || pageSize <= 0) { + console.log(`[Loader] - Invalid or missing pageSize (${pageSize}), defaulting to 10.`); + pageSize = 10; + } + + let parsedFilters: FiltersState = []; + try { + if (filtersParam) { + // Parse and validate filters strictly according to Bazza v0.2 model + parsedFilters = filtersArraySchema.parse(JSON.parse(filtersParam)); + } + } catch (error) { + console.error('[Loader] - Filter parsing/validation error (expecting Bazza v0.2 model):', error); + parsedFilters = []; + } + + // --- Apply filtering, sorting, pagination --- + let processedData = [...mockDatabase]; + + // 1. Apply filters (support option and text types) + if (parsedFilters.length > 0) { + parsedFilters.forEach((filter) => { + processedData = processedData.filter((item) => { + switch (filter.type) { + case 'option': { + // Option filter: support multi-value (is any of) + if (Array.isArray(filter.values) && filter.values.length > 0) { + const value = item[filter.columnId as keyof MockIssue]; + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) { + return filter.values.includes(value); + } + // If value is not a supported type (e.g., Date), skip filtering + return true; + } + return true; + } + case 'text': { + // Text filter: support contains + if (Array.isArray(filter.values) && filter.values.length > 0 && typeof filter.values[0] === 'string') { + const value = item[filter.columnId as keyof MockIssue]; + return typeof value === 'string' && value.toLowerCase().includes(String(filter.values[0]).toLowerCase()); + } + return true; + } + // Add more filter types as needed (number, date, etc.) + default: + return true; + } + }); + }); + } + + // 2. Apply sorting + if (sortField && sortField in mockDatabase[0]) { + processedData.sort((a, b) => { + const aValue = a[sortField as keyof MockIssue]; + const bValue = b[sortField as keyof MockIssue]; + let comparison = 0; + if (aValue < bValue) comparison = -1; + if (aValue > bValue) comparison = 1; + return sortDesc ? comparison * -1 : comparison; + }); + } + + const totalItems = processedData.length; + const totalPages = Math.ceil(totalItems / pageSize); + + // 3. Apply pagination + const start = page * pageSize; + const paginatedData = processedData.slice(start, start + pageSize); + + // Calculate faceted counts based on the filtered data (not the original database) + // This ensures counts reflect the current filtered dataset + const facetedColumns: Array<keyof MockIssue> = ['status', 'assignee', 'priority']; + const facetedCounts = calculateFacetedCounts(processedData, facetedColumns, allDefinedOptions); + + const response: DataResponse = { + data: paginatedData, + meta: { + total: totalItems, + page: page, + pageSize: pageSize, + pageCount: totalPages, + }, + facetedCounts: facetedCounts, + }; + + return response; +}; + +const meta = { + title: 'Data Table/Bazza UI Filters', + component: DataTableWithBazzaFilters, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: DataTableWithBazzaFilters, + loader: handleDataFetch, + }, + ], + }), + ], + tags: ['autodocs'], +} satisfies Meta<typeof DataTableWithBazzaFilters>; + +export default meta; +type Story = StoryObj<typeof meta>; + +// Test functions for the data table with Bazza filters +const testInitialRender = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Check if the table is rendered with the correct title + const title = canvas.getByText('Issues Table (Bazza UI Server Filters via Loader)'); + expect(title).toBeInTheDocument(); + + // Check if the table has the correct number of rows initially (should be pageSize) + const rows = canvas.getAllByRole('row'); + // First row is header, so we expect pageSize + 1 rows + expect(rows.length).toBeGreaterThan(1); // At least header + 1 data row + + // Check if pagination is rendered + const paginationControls = canvas.getByRole('navigation'); + expect(paginationControls).toBeInTheDocument(); +}; + +const testFiltering = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Open the filter dropdown + const filterButton = canvas.getByRole('button', { name: /filter/i }); + await userEvent.click(filterButton); + + // Select a filter type (e.g., Status) + const statusFilter = await canvas.findByText('Status'); + await userEvent.click(statusFilter); + + // Select a filter value (e.g., "Todo") + const todoOption = await canvas.findByText('Todo'); + await userEvent.click(todoOption); + + // Apply the filter + const applyButton = canvas.getByRole('button', { name: /apply/i }); + await userEvent.click(applyButton); + + // Wait for the table to update + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check if the URL has been updated with the filter + expect(window.location.search).toContain('filters'); + + // Check if the filter chip is displayed + const filterChip = await canvas.findByText('Status: Todo'); + expect(filterChip).toBeInTheDocument(); +}; + +const testPagination = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Get the initial page number + const initialPageButton = canvas.getByLabelText(/page 1/i); + expect(initialPageButton).toHaveAttribute('aria-current', 'page'); + + // Click on the next page button + const nextPageButton = canvas.getByLabelText(/go to next page/i); + await userEvent.click(nextPageButton); + + // Wait for the table to update + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check if the URL has been updated with the new page + expect(window.location.search).toContain('page=1'); + + // Check if the page 2 button is now selected + const page2Button = canvas.getByLabelText(/page 2/i); + expect(page2Button).toHaveAttribute('aria-current', 'page'); +}; + +const testFilterPersistence = async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Simulate a page refresh by manually setting the URL with filters + // This is done by checking if the filter chip is still present after pagination + const filterChips = canvas.getAllByRole('button', { name: /remove filter/i }); + expect(filterChips.length).toBeGreaterThan(0); + + // Check if the filtered data is still displayed correctly + // We can verify this by checking if the filter chip is still present + const statusFilterChip = canvas.getByText(/Status:/i); + expect(statusFilterChip).toBeInTheDocument(); +}; + +export const ServerDriven: Story = { + args: {}, + parameters: { + docs: { + description: { + story: + 'Demonstrates server-side filtering (via loader), pagination, and sorting with Bazza UI components and URL state synchronization.', + }, + }, + }, + play: async (context) => { + // Run the tests in sequence + await testInitialRender(context); + await testFiltering(context); + await testPagination(context); + await testFilterPersistence(context); + }, +}; diff --git a/packages/components/package.json b/packages/components/package.json index e93a811a..574624e5 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -42,19 +42,22 @@ "@hookform/resolvers": "^3.9.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.4", - "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-checkbox": "^1.3.1", + "@radix-ui/react-dialog": "^1.1.13", + "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-label": "^2.1.6", + "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", - "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-separator": "^1.1.6", + "@radix-ui/react-slider": "^1.3.4", + "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tooltip": "^1.1.6", - "@tanstack/react-table": "^8.21.2", + "@tanstack/react-query": "^5.75.2", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -62,6 +65,7 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "next-themes": "^0.4.4", + "nuqs": "^2.4.3", "react-day-picker": "8.10.1", "react-hook-form": "^7.53.1", "react-router": "^7.0.0", @@ -70,7 +74,7 @@ "sonner": "^1.7.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", - "zod": "^3.24.1" + "zod": "^3.24.4" }, "devDependencies": { "@react-router/dev": "^7.0.0", diff --git a/packages/components/src/ui/button.tsx b/packages/components/src/ui/button.tsx index b9bbd8e4..d7c8ea94 100644 --- a/packages/components/src/ui/button.tsx +++ b/packages/components/src/ui/button.tsx @@ -1,25 +1,27 @@ import { Slot } from '@radix-ui/react-slot'; import { type VariantProps, cva } from 'class-variance-authority'; -import * as React from 'react'; +import type { ComponentProps } from 'react'; import { cn } from './utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', - outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', - secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', + default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + destructive: + 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', - icon: 'h-10 w-10', + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', }, }, defaultVariants: { @@ -29,23 +31,19 @@ const buttonVariants = cva( }, ); -export interface ButtonProps - extends React.ButtonHTMLAttributes<HTMLButtonElement>, - VariantProps<typeof buttonVariants> { - asChild?: boolean; -} - -export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: ComponentProps<'button'> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean; + }) { const Comp = asChild ? Slot : 'button'; - return ( - <Comp - className={cn(buttonVariants({ variant, size, className }))} - data-slot="button" - {...props} - /> - ); -} -Button.displayName = 'Button'; + return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />; +} -export { buttonVariants }; \ No newline at end of file +export { Button, buttonVariants }; diff --git a/packages/components/src/ui/calendar.tsx b/packages/components/src/ui/calendar.tsx new file mode 100644 index 00000000..516e0455 --- /dev/null +++ b/packages/components/src/ui/calendar.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { DayPicker } from 'react-day-picker'; + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: React.ComponentProps<typeof DayPicker>) { + function cn(arg0: string, className: string | undefined): string | undefined { + throw new Error('Function not implemented.'); + } + + function buttonVariants(arg0: { variant: string }): any { + throw new Error('Function not implemented.'); + } + + return ( + <DayPicker + showOutsideDays={showOutsideDays} + className={cn('p-3', className)} + classNames={{ + months: 'flex flex-col sm:flex-row gap-2', + month: 'flex flex-col gap-4', + caption: 'flex justify-center pt-1 relative items-center w-full', + caption_label: 'text-sm font-medium', + nav: 'flex items-center gap-1', + nav_button: cn( + buttonVariants({ variant: 'outline' }), + 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100', + ), + nav_button_previous: 'absolute left-1', + nav_button_next: 'absolute right-1', + table: 'w-full border-collapse space-x-1', + head_row: 'flex', + head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]', + row: 'flex w-full mt-2', + cell: cn( + 'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md', + props.mode === 'range' + ? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md' + : '[&:has([aria-selected])]:rounded-md', + ), + day: cn(buttonVariants({ variant: 'ghost' }), 'size-8 p-0 font-normal aria-selected:opacity-100'), + day_range_start: 'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground', + day_range_end: 'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground', + day_selected: + 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', + day_today: 'bg-accent text-accent-foreground', + day_outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground', + day_disabled: 'text-muted-foreground opacity-50', + day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground', + day_hidden: 'invisible', + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => <ChevronLeft className={cn('size-4', className)} {...props} />, + IconRight: ({ className, ...props }) => <ChevronRight className={cn('size-4', className)} {...props} />, + }} + {...props} + /> + ); +} + +export { Calendar }; diff --git a/packages/components/src/ui/checkbox.tsx b/packages/components/src/ui/checkbox.tsx new file mode 100644 index 00000000..3e951d15 --- /dev/null +++ b/packages/components/src/ui/checkbox.tsx @@ -0,0 +1,25 @@ +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from 'lucide-react'; +import { cn } from './utils'; + +function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { + return ( + <CheckboxPrimitive.Root + data-slot="checkbox" + className={cn( + 'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', + className, + )} + {...props} + > + <CheckboxPrimitive.Indicator + data-slot="checkbox-indicator" + className="flex items-center justify-center text-current transition-none" + > + <CheckIcon className="size-3.5" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> + ); +} + +export { Checkbox }; diff --git a/packages/components/src/ui/command.tsx b/packages/components/src/ui/command.tsx index 80686eae..56d3b348 100644 --- a/packages/components/src/ui/command.tsx +++ b/packages/components/src/ui/command.tsx @@ -1,7 +1,7 @@ import { Dialog, DialogContent, type DialogProps } from '@radix-ui/react-dialog'; import { Command as CommandPrimitive } from 'cmdk'; import { Search } from 'lucide-react'; -import type * as React from 'react'; +import * as React from 'react'; import { cn } from './utils'; @@ -30,10 +30,14 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => { ); }; -const CommandInput = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>) => ( +const CommandInput = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Input>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> +>(({ className, ...props }, ref) => ( <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <CommandPrimitive.Input + ref={ref} className={cn( 'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50', className, @@ -41,7 +45,7 @@ const CommandInput = ({ className, ...props }: React.ComponentPropsWithoutRef<ty {...props} /> </div> -); +)); CommandInput.displayName = CommandPrimitive.Input.displayName; @@ -78,15 +82,19 @@ const CommandSeparator = ({ CommandSeparator.displayName = CommandPrimitive.Separator.displayName; -const CommandItem = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>) => ( +const CommandItem = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> +>(({ className, ...props }, ref) => ( <CommandPrimitive.Item + ref={ref} className={cn( 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50', className, )} {...props} /> -); +)); CommandItem.displayName = CommandPrimitive.Item.displayName; diff --git a/packages/components/src/ui/data-table-filter/README.md b/packages/components/src/ui/data-table-filter/README.md new file mode 100644 index 00000000..d1d234be --- /dev/null +++ b/packages/components/src/ui/data-table-filter/README.md @@ -0,0 +1,252 @@ +# Data Table Filter Component + +This component provides a powerful filtering system for data tables, with support for various filter types, URL synchronization, and real-time data updates. + +## Features + +- **Filter Types**: Support for text, option, multi-option, number, and date filters +- **URL Synchronization**: Filters are synchronized with URL query parameters for shareable and bookmarkable filter states +- **Real-time Data Updates**: Data is updated in real-time as filters change +- **Faceted Counts**: Display counts for each filter option based on the current data +- **Responsive Design**: Works on both desktop and mobile devices + +## Usage + +### Basic Usage with `useFilteredData` Hook (Recommended) + +The easiest way to use the data table filter is with the `useFilteredData` hook, which handles all the complexity for you: + +```tsx +import { useFilteredData } from '@lambdacurry/forms/ui/utils/use-filtered-data'; +import { DataTableFilter } from '@lambdacurry/forms/ui/data-table-filter/components/data-table-filter'; + +function MyDataTable() { + // Define your column configurations + const columnsConfig = [ + { + id: 'status', + type: 'option', + displayName: 'Status', + accessor: (item) => item.status, + icon: () => null, + options: [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + ], + }, + // Add more columns as needed + ]; + + // Use the hook to handle everything + const { + filters, + columns, + actions, + data, + isLoading, + } = useFilteredData({ + endpoint: '/api/items', // Your API endpoint + columnsConfig, + initialData: [], // Optional initial data + }); + + return ( + <div> + {/* Render the filter component */} + <DataTableFilter + columns={columns} + filters={filters} + actions={actions} + strategy="client" + /> + + {/* Render your data table with the filtered data */} + {isLoading ? ( + <div>Loading...</div> + ) : ( + <table> + {/* Your table implementation */} + </table> + )} + </div> + ); +} +``` + +### Advanced Usage with Manual Setup + +If you need more control, you can set up the filters manually: + +```tsx +import { useFilterSync } from '@lambdacurry/forms/ui/utils/use-filter-sync'; +import { useDataQuery } from '@lambdacurry/forms/ui/utils/use-issues-query'; +import { createColumns } from '@lambdacurry/forms/ui/data-table-filter/core/filters'; +import { DataTableFilter } from '@lambdacurry/forms/ui/data-table-filter/components/data-table-filter'; + +function MyDataTable() { + // Sync filters with URL + const [filters, setFilters] = useFilterSync(); + + // Fetch data with filters + const { data, isLoading } = useDataQuery('/api/items', filters); + + // Define column configurations + const columnsConfig = [/* your column configs */]; + + // Create columns with faceted counts + const columns = useMemo(() => { + if (!data) return createColumns([], columnsConfig, 'client'); + + // Apply faceted counts from the API + const enhancedConfig = columnsConfig.map(config => { + if (config.type === 'option' && data.facetedCounts?.[config.id]) { + return { + ...config, + facetedOptions: new Map( + Object.entries(data.facetedCounts[config.id]) + .map(([key, count]) => [key, count]) + ) + }; + } + return config; + }); + + return createColumns(data.data || [], enhancedConfig, 'client'); + }, [data, columnsConfig]); + + // Create filter actions + const actions = useMemo(() => { + return { + addFilterValue: (column, values) => { + // Implementation + }, + removeFilterValue: (column, values) => { + // Implementation + }, + setFilterValue: (column, values) => { + // Implementation + }, + setFilterOperator: (columnId, operator) => { + // Implementation + }, + removeFilter: (columnId) => { + // Implementation + }, + removeAllFilters: () => { + // Implementation + } + }; + }, [setFilters]); + + return ( + <div> + <DataTableFilter + columns={columns} + filters={filters} + actions={actions} + strategy="client" + /> + + {/* Your table implementation */} + </div> + ); +} +``` + +## API Reference + +### `DataTableFilter` Component + +```tsx +<DataTableFilter + columns={columns} + filters={filters} + actions={actions} + strategy="client" + locale="en" +/> +``` + +#### Props + +- `columns`: Array of column definitions with filter options +- `filters`: Current filter state +- `actions`: Object containing filter action functions +- `strategy`: Filter strategy, either "client" or "server" +- `locale`: Optional locale for internationalization (default: "en") + +### `useFilteredData` Hook + +```tsx +const { + filters, + setFilters, + columns, + actions, + data, + facetedCounts, + isLoading, + isError, + error, + refetch, +} = useFilteredData({ + endpoint, + columnsConfig, + strategy, + initialData, + queryOptions, +}); +``` + +#### Parameters + +- `endpoint`: API endpoint to fetch data from +- `columnsConfig`: Array of column configurations +- `strategy`: Filter strategy, either "client" or "server" (default: "client") +- `initialData`: Optional initial data to use before API data is loaded +- `queryOptions`: Additional options for the query + +#### Returns + +- `filters`: Current filter state +- `setFilters`: Function to update filters +- `columns`: Columns with faceted counts +- `actions`: Filter action functions +- `data`: Filtered data +- `facetedCounts`: Counts for each filter option +- `isLoading`: Whether data is currently loading +- `isError`: Whether an error occurred +- `error`: Error object if an error occurred +- `refetch`: Function to manually refetch data + +## Server-Side Implementation + +For the filters to work correctly with faceted counts, your API should return data in the following format: + +```json +{ + "data": [ + // Your data items + ], + "facetedCounts": { + "status": { + "active": 10, + "inactive": 5 + }, + "category": { + "electronics": 7, + "clothing": 8 + } + } +} +``` + +The `facetedCounts` object should contain counts for each filter option, organized by column ID. + +## Examples + +See the `examples` directory for complete working examples: + +- `data-table-filter-example.tsx`: Comprehensive example with API integration +- `simplified-example.tsx`: Simplified example using the `useFilteredData` hook + diff --git a/packages/components/src/ui/data-table-filter/components/active-filters.tsx b/packages/components/src/ui/data-table-filter/components/active-filters.tsx new file mode 100644 index 00000000..773c2572 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/active-filters.tsx @@ -0,0 +1,156 @@ +import { X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Button } from '../../button'; +import { Separator } from '../../separator'; +import type { + Column, + ColumnDataType, + DataTableFilterActions, + FilterModel, + FilterStrategy, + FiltersState, +} from '../core/types'; +import { getColumn } from '../lib/helpers'; +import type { Locale } from '../lib/i18n'; +import { FilterOperator } from './filter-operator'; +import { FilterSubject } from './filter-subject'; +import { FilterValue } from './filter-value'; + +interface ActiveFiltersProps<TData> { + columns: Column<TData>[]; + filters: FiltersState; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export function ActiveFilters<TData>({ + columns, + filters, + actions, + strategy, + locale = 'en', +}: ActiveFiltersProps<TData>) { + return ( + <> + {filters.map((filter) => { + const id = filter.columnId; + + const column = getColumn(columns, id); + + // Skip if no filter value + if (!filter.values) return null; + + return ( + <ActiveFilter + key={`active-filter-${filter.columnId}`} + filter={filter} + column={column} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + })} + </> + ); +} + +interface ActiveFilterProps<TData, TType extends ColumnDataType> { + filter: FilterModel<TType>; + column: Column<TData, TType>; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +// Generic render function for a filter with type-safe value +export function ActiveFilter<TData, TType extends ColumnDataType>({ + filter, + column, + actions, + strategy, + locale = 'en', +}: ActiveFilterProps<TData, TType>) { + return ( + <div className="flex h-7 items-center rounded-2xl border border-border bg-background shadow-xs text-xs"> + <FilterSubject column={column} /> + <Separator orientation="vertical" /> + <FilterOperator filter={filter} column={column} actions={actions} locale={locale} /> + <Separator orientation="vertical" /> + <FilterValue filter={filter} column={column} actions={actions} strategy={strategy} locale={locale} /> + <Separator orientation="vertical" /> + <Button + variant="ghost" + className="rounded-none rounded-r-2xl text-xs w-7 h-full" + onClick={() => actions.removeFilter(filter.columnId)} + > + <X className="size-4 -translate-x-0.5" /> + </Button> + </div> + ); +} + +export function ActiveFiltersMobileContainer({ children }: { children: React.ReactNode }) { + const scrollContainerRef = useRef<HTMLDivElement>(null); + const [showLeftBlur, setShowLeftBlur] = useState(false); + const [showRightBlur, setShowRightBlur] = useState(true); + + // Check if there's content to scroll and update blur states + const checkScroll = () => { + if (scrollContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current; + + // Show left blur if scrolled to the right + setShowLeftBlur(scrollLeft > 0); + + // Show right blur if there's more content to scroll to the right + // Add a small buffer (1px) to account for rounding errors + setShowRightBlur(scrollLeft + clientWidth < scrollWidth - 1); + } + }; + + // Log blur states for debugging + // useEffect(() => { + // console.log('left:', showLeftBlur, ' right:', showRightBlur) + // }, [showLeftBlur, showRightBlur]) + + // Set up ResizeObserver to monitor container size + // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> + useEffect(() => { + if (scrollContainerRef.current) { + const resizeObserver = new ResizeObserver(() => { + checkScroll(); + }); + resizeObserver.observe(scrollContainerRef.current); + return () => { + resizeObserver.disconnect(); + }; + } + }, []); + + // Update blur states when children change + // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> + useEffect(() => { + checkScroll(); + }, [children]); + + return ( + <div className="relative w-full overflow-x-hidden"> + {/* Left blur effect */} + {showLeftBlur && ( + <div className="absolute left-0 top-0 bottom-0 w-16 z-10 pointer-events-none bg-gradient-to-r from-background to-transparent animate-in fade-in-0" /> + )} + + {/* Scrollable container */} + <div ref={scrollContainerRef} className="flex gap-2 overflow-x-scroll no-scrollbar" onScroll={checkScroll}> + {children} + </div> + + {/* Right blur effect */} + {showRightBlur && ( + <div className="absolute right-0 top-0 bottom-0 w-16 z-10 pointer-events-none bg-gradient-to-l from-background to-transparent animate-in fade-in-0 " /> + )} + </div> + ); +} diff --git a/packages/components/src/ui/data-table-filter/components/data-table-filter.tsx b/packages/components/src/ui/data-table-filter/components/data-table-filter.tsx new file mode 100644 index 00000000..b53bee71 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/data-table-filter.tsx @@ -0,0 +1,48 @@ +import type { Column, DataTableFilterActions, FilterStrategy, FiltersState } from '../core/types'; +import type { Locale } from '../lib/i18n'; +import { ActiveFilters, ActiveFiltersMobileContainer } from './active-filters'; +import { FilterActions } from './filter-actions'; +import { FilterSelector } from './filter-selector'; + +interface DataTableFilterProps<TData> { + columns: Column<TData>[]; + filters: FiltersState; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export function DataTableFilter<TData>({ + columns, + filters, + actions, + strategy, + locale = 'en', +}: DataTableFilterProps<TData>) { + return ( + <div className="flex w-full items-start justify-between gap-2"> + {/* Left Group: Selector + Desktop Active Filters */} + <div className="flex flex-1 flex-wrap items-center gap-2"> + <FilterSelector columns={columns} filters={filters} actions={actions} strategy={strategy} locale={locale} /> + + {/* Desktop Active Filters: Hidden below md */} + <div className="hidden md:flex"> + <ActiveFilters columns={columns} filters={filters} actions={actions} strategy={strategy} locale={locale} /> + </div> + </div> + + {/* Right Group: Actions + Mobile Active Filters */} + <div className="flex items-center gap-2"> + {/* Filter Actions (Always visible, but maybe redundant now?) */} + <FilterActions hasFilters={filters.length > 0} actions={actions} locale={locale} /> + + {/* Mobile Active Filters Container: Visible below md */} + <div className="flex md:hidden"> + <ActiveFiltersMobileContainer> + <ActiveFilters columns={columns} filters={filters} actions={actions} strategy={strategy} locale={locale} /> + </ActiveFiltersMobileContainer> + </div> + </div> + </div> + ); +} diff --git a/packages/components/src/ui/data-table-filter/components/debounced-input.tsx b/packages/components/src/ui/data-table-filter/components/debounced-input.tsx new file mode 100644 index 00000000..75ec215b --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/debounced-input.tsx @@ -0,0 +1,35 @@ +import { type ChangeEvent, type InputHTMLAttributes, useEffect, useMemo, useState } from 'react'; +import { TextInput } from '../..'; +import { debounce } from '../lib/debounce'; + +export function DebouncedInput({ + value: initialValue, + onChange, + debounceMs = 500, // This is the wait time, not the function + ...props +}: { + value: string | number; + onChange: (value: string | number) => void; + debounceMs?: number; +} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'>) { + const [value, setValue] = useState(initialValue); + + // Sync with initialValue when it changes + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + // Define the debounced function with useMemo + const debouncedOnChange = useMemo( + () => debounce((newValue: string | number) => onChange(newValue), debounceMs), + [debounceMs, onChange], + ); + + const handleChange = (e: ChangeEvent<HTMLInputElement>) => { + const newValue = e.target.value; + setValue(newValue); // Update local state immediately + debouncedOnChange(newValue); // Call debounced version + }; + + return <TextInput {...props} value={value} onChange={handleChange} />; +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-actions.tsx b/packages/components/src/ui/data-table-filter/components/filter-actions.tsx new file mode 100644 index 00000000..3e8fa643 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-actions.tsx @@ -0,0 +1,26 @@ +import { FilterXIcon } from 'lucide-react'; +import { memo } from 'react'; +import { Button } from '../../button'; +import { cn } from '../../utils'; +import type { DataTableFilterActions } from '../core/types'; +import { type Locale, t } from '../lib/i18n'; + +interface FilterActionsProps { + hasFilters: boolean; + actions?: DataTableFilterActions; + locale?: Locale; +} + +export const FilterActions = memo(__FilterActions); +function __FilterActions({ hasFilters, actions, locale = 'en' }: FilterActionsProps) { + return ( + <Button + variant="secondary" + className={cn('h-7 !px-2', !hasFilters && 'hidden')} + onClick={actions?.removeAllFilters} + > + <FilterXIcon /> + <span className="hidden md:block">{t('clear', locale)}</span> + </Button> + ); +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-operator.tsx b/packages/components/src/ui/data-table-filter/components/filter-operator.tsx new file mode 100644 index 00000000..8997ed04 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-operator.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { Button } from '../../button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../../popover'; +import { + dateFilterOperators, + filterTypeOperatorDetails, + multiOptionFilterOperators, + numberFilterOperators, + optionFilterOperators, + textFilterOperators, +} from '../core/operators'; +import type { Column, ColumnDataType, DataTableFilterActions, FilterModel, FilterOperators } from '../core/types'; +import { type Locale, t } from '../lib/i18n'; + +interface FilterOperatorProps<TData, TType extends ColumnDataType> { + column: Column<TData, TType>; + filter: FilterModel<TType>; + actions: DataTableFilterActions; + locale?: Locale; +} + +// Renders the filter operator display and menu for a given column filter +// The filter operator display is the label and icon for the filter operator +// The filter operator menu is the dropdown menu for the filter operator +export function FilterOperator<TData, TType extends ColumnDataType>({ + column, + filter, + actions, + locale = 'en', +}: FilterOperatorProps<TData, TType>) { + const [open, setOpen] = useState<boolean>(false); + + const close = () => setOpen(false); + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button variant="ghost" className="m-0 h-full w-fit whitespace-nowrap rounded-none p-0 px-2 text-xs"> + <FilterOperatorDisplay filter={filter} columnType={column.type} locale={locale} /> + </Button> + </PopoverTrigger> + <PopoverContent align="start" className="w-fit p-0 origin-(--radix-popover-content-transform-origin)"> + <Command loop> + <CommandInput placeholder={t('search', locale)} /> + <CommandEmpty>{t('noresults', locale)}</CommandEmpty> + <CommandList className="max-h-fit"> + <FilterOperatorController + filter={filter} + column={column} + actions={actions} + closeController={close} + locale={locale} + /> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +} + +interface FilterOperatorDisplayProps<TType extends ColumnDataType> { + filter: FilterModel<TType>; + columnType: TType; + locale?: Locale; +} + +export function FilterOperatorDisplay<TType extends ColumnDataType>({ + filter, + columnType, + locale = 'en', +}: FilterOperatorDisplayProps<TType>) { + const operator = filterTypeOperatorDetails[columnType][filter.operator]; + const label = t(operator.key, locale); + + return <span className="text-muted-foreground">{label}</span>; +} + +interface FilterOperatorControllerProps<TData, TType extends ColumnDataType> { + filter: FilterModel<TType>; + column: Column<TData, TType>; + actions: DataTableFilterActions; + closeController: () => void; + locale?: Locale; +} + +/* + * + * TODO: Reduce into a single component. Each data type does not need it's own controller. + * + */ +export function FilterOperatorController<TData, TType extends ColumnDataType>({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps<TData, TType>) { + switch (column.type) { + case 'option': + return ( + <FilterOperatorOptionController + filter={filter as FilterModel<'option'>} + column={column as Column<TData, 'option'>} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + case 'multiOption': + return ( + <FilterOperatorMultiOptionController + filter={filter as FilterModel<'multiOption'>} + column={column as Column<TData, 'multiOption'>} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + case 'date': + return ( + <FilterOperatorDateController + filter={filter as FilterModel<'date'>} + column={column as Column<TData, 'date'>} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + case 'text': + return ( + <FilterOperatorTextController + filter={filter as FilterModel<'text'>} + column={column as Column<TData, 'text'>} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + case 'number': + return ( + <FilterOperatorNumberController + filter={filter as FilterModel<'number'>} + column={column as Column<TData, 'number'>} + actions={actions} + closeController={closeController} + locale={locale} + /> + ); + default: + return null; + } +} + +function FilterOperatorOptionController<TData>({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps<TData, 'option'>) { + const filterDetails = optionFilterOperators[filter.operator]; + + const relatedFilters = Object.values(optionFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['option']); + closeController(); + }; + + return ( + <CommandGroup heading={t('operators', locale)}> + {relatedFilters.map((r) => { + return ( + <CommandItem onSelect={changeOperator} value={r.value} key={r.value}> + {t(r.key, locale)} + </CommandItem> + ); + })} + </CommandGroup> + ); +} + +function FilterOperatorMultiOptionController<TData>({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps<TData, 'multiOption'>) { + const filterDetails = multiOptionFilterOperators[filter.operator]; + + const relatedFilters = Object.values(multiOptionFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['multiOption']); + closeController(); + }; + + return ( + <CommandGroup heading={t('operators', locale)}> + {relatedFilters.map((r) => { + return ( + <CommandItem onSelect={changeOperator} value={r.value} key={r.value}> + {t(r.key, locale)} + </CommandItem> + ); + })} + </CommandGroup> + ); +} + +function FilterOperatorDateController<TData>({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps<TData, 'date'>) { + const filterDetails = dateFilterOperators[filter.operator]; + + const relatedFilters = Object.values(dateFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['date']); + closeController(); + }; + + return ( + <CommandGroup> + {relatedFilters.map((r) => { + return ( + <CommandItem onSelect={changeOperator} value={r.value} key={r.value}> + {t(r.key, locale)} + </CommandItem> + ); + })} + </CommandGroup> + ); +} + +export function FilterOperatorTextController<TData>({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps<TData, 'text'>) { + const filterDetails = textFilterOperators[filter.operator]; + + const relatedFilters = Object.values(textFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['text']); + closeController(); + }; + + return ( + <CommandGroup heading={t('operators', locale)}> + {relatedFilters.map((r) => { + return ( + <CommandItem onSelect={changeOperator} value={r.value} key={r.value}> + {t(r.key, locale)} + </CommandItem> + ); + })} + </CommandGroup> + ); +} + +function FilterOperatorNumberController<TData>({ + filter, + column, + actions, + closeController, + locale = 'en', +}: FilterOperatorControllerProps<TData, 'number'>) { + const filterDetails = numberFilterOperators[filter.operator]; + + const relatedFilters = Object.values(numberFilterOperators).filter((o) => o.target === filterDetails.target); + + const changeOperator = (value: string) => { + actions?.setFilterOperator(column.id, value as FilterOperators['number']); + closeController(); + }; + + return ( + <div> + <CommandGroup heading={t('operators', locale)}> + {relatedFilters.map((r) => ( + <CommandItem onSelect={() => changeOperator(r.value)} value={r.value} key={r.value}> + {t(r.key, locale)} + </CommandItem> + ))} + </CommandGroup> + </div> + ); +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-selector.tsx b/packages/components/src/ui/data-table-filter/components/filter-selector.tsx new file mode 100644 index 00000000..72ee362b --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-selector.tsx @@ -0,0 +1,268 @@ +import { Checkbox } from '@radix-ui/react-checkbox'; +import { ArrowRightIcon, ChevronRightIcon, FilterIcon } from 'lucide-react'; +import { Fragment, isValidElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../../popover'; + +import { Button } from '../../button'; +import { cn } from '../../utils'; +import type { + Column, + ColumnDataType, + DataTableFilterActions, + FilterModel, + FilterStrategy, + FiltersState, +} from '../core/types'; +import { isAnyOf } from '../lib/array'; +import { getColumn } from '../lib/helpers'; +import { type Locale, t } from '../lib/i18n'; +import { FilterValueController } from './filter-value'; + +interface FilterSelectorProps<TData> { + filters: FiltersState; + columns: Column<TData>[]; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export const FilterSelector = memo(__FilterSelector) as typeof __FilterSelector; + +function __FilterSelector<TData>({ filters, columns, actions, strategy, locale = 'en' }: FilterSelectorProps<TData>) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(''); + const [property, setProperty] = useState<string | undefined>(undefined); + const inputRef = useRef<HTMLInputElement>(null); + + const column = property ? getColumn(columns, property) : undefined; + const filter = property ? filters.find((f) => f.columnId === property) : undefined; + + const hasFilters = filters.length > 0; + + useEffect(() => { + if (property && inputRef) { + inputRef.current?.focus(); + setValue(''); + } + }, [property]); + + useEffect(() => { + if (!open) setTimeout(() => setValue(''), 150); + }, [open]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: need filters to be updated + const content = useMemo( + () => + property && column ? ( + <FilterValueController + filter={filter as FilterModel<ColumnDataType>} + column={column as Column<TData, ColumnDataType>} + actions={actions} + strategy={strategy} + locale={locale} + /> + ) : ( + <Command + loop + filter={(value: string, search: string, keywords?: string[]) => { + const extendValue = `${value} ${keywords ? keywords.join(' ') : ''}`; + return extendValue.includes(search) ? 1 : 0; + }} + > + <CommandInput value={value} onValueChange={setValue} ref={inputRef} placeholder={t('search', locale)} /> + <CommandEmpty>{t('noresults', locale)}</CommandEmpty> + <CommandList className="max-h-fit"> + <CommandGroup> + {columns.map((column) => ( + <FilterableColumn key={column.id} column={column} setProperty={setProperty} /> + ))} + <QuickSearchFilters + search={value} + filters={filters} + columns={columns} + actions={actions} + strategy={strategy} + locale={locale} + /> + </CommandGroup> + </CommandList> + </Command> + ), + [property, column, filter, filters, columns, actions, value], + ); + + return ( + <Popover + open={open} + onOpenChange={async (value) => { + setOpen(value); + if (!value) setTimeout(() => setProperty(undefined), 100); + }} + > + <PopoverTrigger asChild> + <Button variant="outline" className={cn('h-7', hasFilters && 'w-fit !px-2')}> + <FilterIcon className="size-4" /> + {!hasFilters && <span>{t('filters', locale)}</span>} + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + side="bottom" + className="w-fit p-0 origin-(--radix-popover-content-transform-origin)" + > + {content} + </PopoverContent> + </Popover> + ); +} + +export function FilterableColumn<TData, TType extends ColumnDataType, TVal>({ + column, + setProperty, +}: { + column: Column<TData, TType, TVal>; + setProperty: (value: string) => void; +}) { + const itemRef = useRef<HTMLDivElement>(null); + + const prefetch = useCallback(() => { + column.prefetchOptions(); + column.prefetchValues(); + column.prefetchFacetedUniqueValues(); + column.prefetchFacetedMinMaxValues(); + }, [column]); + + useEffect(() => { + const target = itemRef.current; + + if (!target) return; + + // Set up MutationObserver + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + const isSelected = target.getAttribute('data-selected') === 'true'; + if (isSelected) prefetch(); + } + } + }); + + // Set up observer + observer.observe(target, { + attributes: true, + attributeFilter: ['data-selected'], + }); + + // Cleanup on unmount + return () => observer.disconnect(); + }, [prefetch]); + + return ( + <CommandItem + ref={itemRef} + value={column.id} + keywords={[column.displayName]} + onSelect={() => setProperty(column.id)} + className="group" + onMouseEnter={prefetch} + > + <div className="flex w-full items-center justify-between"> + <div className="inline-flex items-center gap-1.5"> + {<column.icon strokeWidth={2.25} className="size-4" />} + <span>{column.displayName}</span> + </div> + <ArrowRightIcon className="size-4 opacity-0 group-aria-selected:opacity-100" /> + </div> + </CommandItem> + ); +} + +interface QuickSearchFiltersProps<TData> { + search?: string; + filters: FiltersState; + columns: Column<TData>[]; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export const QuickSearchFilters = memo(__QuickSearchFilters) as typeof __QuickSearchFilters; + +function __QuickSearchFilters<TData>({ + search, + filters, + columns, + actions, + strategy, + locale = 'en', +}: QuickSearchFiltersProps<TData>) { + if (!search || search.trim().length < 2) return null; + + const cols = useMemo( + () => columns.filter((c) => isAnyOf<ColumnDataType>(c.type, ['option', 'multiOption'])), + [columns], + ); + + return ( + <> + {cols.map((column) => { + const filter = filters.find((f) => f.columnId === column.id); + const options = column.getOptions(); + const optionsCount = column.getFacetedUniqueValues(); + + function handleOptionSelect(value: string, check: boolean) { + if (check) actions.addFilterValue(column, [value]); + else actions.removeFilterValue(column, [value]); + } + + return ( + <Fragment key={column.id}> + {options.map((v) => { + const checked = Boolean(filter?.values.includes(v.value)); + const count = optionsCount?.get(v.value) ?? 0; + + return ( + <CommandItem + key={v.value} + value={v.value} + keywords={[v.label, v.value]} + onSelect={() => { + handleOptionSelect(v.value, !checked); + }} + className="group" + > + <div className="flex items-center gap-1.5 group"> + <Checkbox + checked={checked} + className="opacity-0 data-[state=checked]:opacity-100 group-data-[selected=true]:opacity-100 dark:border-ring mr-1" + /> + <div className="flex items-center w-4 justify-center"> + {v.icon && (isValidElement(v.icon) ? v.icon : <v.icon className="size-4 text-primary" />)} + </div> + <div className="flex items-center gap-0.5"> + <span className="text-muted-foreground">{column.displayName}</span> + <ChevronRightIcon className="size-3.5 text-muted-foreground/75" /> + <span> + {v.label} + <sup + className={cn( + !optionsCount && 'hidden', + 'ml-0.5 tabular-nums tracking-tight text-muted-foreground', + count === 0 && 'slashed-zero', + )} + > + {count < 100 ? count : '100+'} + </sup> + </span> + </div> + </div> + </CommandItem> + ); + })} + </Fragment> + ); + })} + </> + ); +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-subject.tsx b/packages/components/src/ui/data-table-filter/components/filter-subject.tsx new file mode 100644 index 00000000..60b3217c --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-subject.tsx @@ -0,0 +1,17 @@ +import type { Column, ColumnDataType } from '../core/types' + +interface FilterSubjectProps<TData, TType extends ColumnDataType> { + column: Column<TData, TType> +} + +export function FilterSubject<TData, TType extends ColumnDataType>({ + column, +}: FilterSubjectProps<TData, TType>) { + const hasIcon = !!column.icon + return ( + <span className="flex select-none items-center gap-1 whitespace-nowrap px-2 font-medium"> + {hasIcon && <column.icon className="size-4 stroke-[2.25px]" />} + <span>{column.displayName}</span> + </span> + ) +} diff --git a/packages/components/src/ui/data-table-filter/components/filter-value.tsx b/packages/components/src/ui/data-table-filter/components/filter-value.tsx new file mode 100644 index 00000000..fc8b6027 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/components/filter-value.tsx @@ -0,0 +1,741 @@ +import { isEqual } from 'date-fns'; +import { format } from 'date-fns'; +import { Calendar, Ellipsis } from 'lucide-react'; +import { type ElementType, cloneElement, isValidElement, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type { DateRange } from 'react-day-picker'; +import { Button } from '../../button'; +import { Checkbox } from '../../checkbox'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '../../command'; +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '../../popover'; +import { Slider } from '../../slider'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../tabs'; +import { cn } from '../../utils'; +import { useDebounceCallback } from '../../utils/use-debounce-callback'; +import { numberFilterOperators } from '../core/operators'; +import type { + Column, + ColumnDataType, + ColumnOptionExtended, + DataTableFilterActions, + FilterModel, + FilterStrategy, +} from '../core/types'; +import { take } from '../lib/array'; +import { createNumberRange } from '../lib/helpers'; +import { type Locale, t } from '../lib/i18n'; +import { DebouncedInput } from './debounced-input'; + +interface FilterValueProps<TData, TType extends ColumnDataType> { + filter: FilterModel<TType>; + column: Column<TData, TType>; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export const FilterValue = memo(__FilterValue) as typeof __FilterValue; + +function __FilterValue<TData, TType extends ColumnDataType>({ + filter, + column, + actions, + strategy, + locale, +}: FilterValueProps<TData, TType>) { + return ( + <Popover> + <PopoverAnchor className="h-full" /> + <PopoverTrigger asChild> + <Button variant="ghost" className="m-0 h-full w-fit whitespace-nowrap rounded-none p-0 px-2 text-xs"> + <FilterValueDisplay filter={filter} column={column} actions={actions} locale={locale} /> + </Button> + </PopoverTrigger> + <PopoverContent + align="start" + side="bottom" + className="w-fit p-0 origin-(--radix-popover-content-transform-origin)" + > + <FilterValueController filter={filter} column={column} actions={actions} strategy={strategy} locale={locale} /> + </PopoverContent> + </Popover> + ); +} + +interface FilterValueDisplayProps<TData, TType extends ColumnDataType> { + filter: FilterModel<TType>; + column: Column<TData, TType>; + actions: DataTableFilterActions; + locale?: Locale; +} + +export function FilterValueDisplay<TData, TType extends ColumnDataType>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps<TData, TType>) { + switch (column.type) { + case 'option': + return ( + <FilterValueOptionDisplay + filter={filter as FilterModel<'option'>} + column={column as Column<TData, 'option'>} + actions={actions} + locale={locale} + /> + ); + case 'multiOption': + return ( + <FilterValueMultiOptionDisplay + filter={filter as FilterModel<'multiOption'>} + column={column as Column<TData, 'multiOption'>} + actions={actions} + locale={locale} + /> + ); + case 'date': + return ( + <FilterValueDateDisplay + filter={filter as FilterModel<'date'>} + column={column as Column<TData, 'date'>} + actions={actions} + locale={locale} + /> + ); + case 'text': + return ( + <FilterValueTextDisplay + filter={filter as FilterModel<'text'>} + column={column as Column<TData, 'text'>} + actions={actions} + locale={locale} + /> + ); + case 'number': + return ( + <FilterValueNumberDisplay + filter={filter as FilterModel<'number'>} + column={column as Column<TData, 'number'>} + actions={actions} + locale={locale} + /> + ); + default: + return null; + } +} + +export function FilterValueOptionDisplay<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps<TData, 'option'>) { + const options = useMemo(() => column.getOptions(), [column]); + const selected = options.filter((o) => filter?.values.includes(o.value)); + + // We display the selected options based on how many are selected + // + // If there is only one option selected, we display its icon and label + // + // If there are multiple options selected, we display: + // 1) up to 3 icons of the selected options + // 2) the number of selected options + if (selected.length === 1) { + const { label, icon: Icon } = selected[0]; + const hasIcon = !!Icon; + return ( + <span className="inline-flex items-center gap-1"> + {hasIcon && (isValidElement(Icon) ? Icon : <Icon className="size-4 text-primary" />)} + <span>{label}</span> + </span> + ); + } + const name = column.displayName.toLowerCase(); + // TODO: Better pluralization for different languages + const pluralName = name.endsWith('s') ? `${name}es` : `${name}s`; + + const hasOptionIcons = !options?.some((o) => !o.icon); + + return ( + <div className="inline-flex items-center gap-0.5"> + {hasOptionIcons && + take(selected, 3).map(({ value, icon }) => { + const Icon = icon as ElementType; + return isValidElement(Icon) ? Icon : <Icon key={value} className="size-4" />; + })} + <span className={cn(hasOptionIcons && 'ml-1.5')}> + {selected.length} {pluralName} + </span> + </div> + ); +} + +export function FilterValueMultiOptionDisplay<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps<TData, 'multiOption'>) { + const options = useMemo(() => column.getOptions(), [column]); + const selected = options.filter((o) => filter.values.includes(o.value)); + + if (selected.length === 1) { + const { label, icon: Icon } = selected[0]; + const hasIcon = !!Icon; + return ( + <span className="inline-flex items-center gap-1.5"> + {hasIcon && (isValidElement(Icon) ? Icon : <Icon className="size-4 text-primary" />)} + + <span>{label}</span> + </span> + ); + } + + const name = column.displayName.toLowerCase(); + + const hasOptionIcons = !options?.some((o) => !o.icon); + + return ( + <div className="inline-flex items-center gap-1.5"> + {hasOptionIcons && ( + <div key="icons" className="inline-flex items-center gap-0.5"> + {take(selected, 3).map(({ value, icon }) => { + const Icon = icon as ElementType; + return isValidElement(Icon) ? cloneElement(Icon, { key: value }) : <Icon key={value} className="size-4" />; + })} + </div> + )} + <span> + {selected.length} {name} + </span> + </div> + ); +} + +function formatDateRange(start: Date, end: Date) { + const sameMonth = start.getMonth() === end.getMonth(); + const sameYear = start.getFullYear() === end.getFullYear(); + + if (sameMonth && sameYear) { + return `${format(start, 'MMM d')} - ${format(end, 'd, yyyy')}`; + } + + if (sameYear) { + return `${format(start, 'MMM d')} - ${format(end, 'MMM d, yyyy')}`; + } + + return `${format(start, 'MMM d, yyyy')} - ${format(end, 'MMM d, yyyy')}`; +} + +export function FilterValueDateDisplay<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps<TData, 'date'>) { + if (!filter) return null; + if (filter.values.length === 0) return <Ellipsis className="size-4" />; + if (filter.values.length === 1) { + const value = filter.values[0]; + + const formattedDateStr = format(value, 'MMM d, yyyy'); + + return <span>{formattedDateStr}</span>; + } + + const formattedRangeStr = formatDateRange(filter.values[0], filter.values[1]); + + return <span>{formattedRangeStr}</span>; +} + +export function FilterValueTextDisplay<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps<TData, 'text'>) { + if (!filter) return null; + if (filter.values.length === 0 || filter.values[0].trim() === '') return <Ellipsis className="size-4" />; + + const value = filter.values[0]; + + return <span>{value}</span>; +} + +export function FilterValueNumberDisplay<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueDisplayProps<TData, 'number'>) { + if (!filter?.values || filter.values.length === 0) return null; + + if (filter.operator === 'is between' || filter.operator === 'is not between') { + const minValue = filter.values[0]; + const maxValue = filter.values[1]; + + return ( + <span className="tabular-nums tracking-tight"> + {minValue} {t('and', locale)} {maxValue} + </span> + ); + } + + const value = filter.values[0]; + return <span className="tabular-nums tracking-tight">{value}</span>; +} + +/****** Property Filter Value Controller ******/ + +interface FilterValueControllerProps<TData, TType extends ColumnDataType> { + filter: FilterModel<TType>; + column: Column<TData, TType>; + actions: DataTableFilterActions; + strategy: FilterStrategy; + locale?: Locale; +} + +export const FilterValueController = memo(__FilterValueController) as typeof __FilterValueController; + +function __FilterValueController<TData, TType extends ColumnDataType>({ + filter, + column, + actions, + strategy, + locale = 'en', +}: FilterValueControllerProps<TData, TType>) { + switch (column.type) { + case 'option': + return ( + <FilterValueOptionController + filter={filter as FilterModel<'option'>} + column={column as Column<TData, 'option'>} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + case 'multiOption': + return ( + <FilterValueMultiOptionController + filter={filter as FilterModel<'multiOption'>} + column={column as Column<TData, 'multiOption'>} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + case 'date': + return ( + <FilterValueDateController + filter={filter as FilterModel<'date'>} + column={column as Column<TData, 'date'>} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + case 'text': + return ( + <FilterValueTextController + filter={filter as FilterModel<'text'>} + column={column as Column<TData, 'text'>} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + case 'number': + return ( + <FilterValueNumberController + filter={filter as FilterModel<'number'>} + column={column as Column<TData, 'number'>} + actions={actions} + strategy={strategy} + locale={locale} + /> + ); + default: + return null; + } +} + +interface OptionItemProps { + option: ColumnOptionExtended & { initialSelected: boolean }; + onToggle: (value: string, checked: boolean) => void; +} + +// Memoized option item to prevent re-renders unless its own props change +const OptionItem = memo(function OptionItem({ option, onToggle }: OptionItemProps) { + const { value, label, icon: Icon, selected, count } = option; + const handleSelect = useCallback(() => { + onToggle(value, !selected); + }, [onToggle, value, selected]); + + return ( + <CommandItem key={value} onSelect={handleSelect} className="group flex items-center justify-between gap-1.5"> + <div className="flex items-center gap-1.5"> + <Checkbox + checked={selected} + className="opacity-0 data-[state=checked]:opacity-100 group-data-[selected=true]:opacity-100 dark:border-ring mr-1" + /> + {Icon && (isValidElement(Icon) ? Icon : <Icon className="size-4 text-primary" />)} + <span> + {label} + <sup + className={cn( + count == null && 'hidden', + 'ml-0.5 tabular-nums tracking-tight text-muted-foreground', + count === 0 && 'slashed-zero', + )} + > + {typeof count === 'number' ? (count < 100 ? count : '100+') : ''} + </sup> + </span> + </div> + </CommandItem> + ); +}); + +export function FilterValueOptionController<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueControllerProps<TData, 'option'>) { + // Compute initial options once per mount + const initialOptions = useMemo(() => { + const counts = column.getFacetedUniqueValues(); + return column.getOptions().map((o) => ({ + ...o, + selected: filter?.values.includes(o.value), + initialSelected: filter?.values.includes(o.value), + count: counts?.get(o.value) ?? 0, + })); + }, []); + + const [options, setOptions] = useState(initialOptions); + + // Update selected state when filter values change + useEffect(() => { + setOptions((prev) => prev.map((o) => ({ ...o, selected: filter?.values.includes(o.value) }))); + }, [filter?.values]); + + const handleToggle = useCallback( + (value: string, checked: boolean) => { + if (checked) actions.addFilterValue(column, [value]); + else actions.removeFilterValue(column, [value]); + }, + [actions, column], + ); + + // Derive groups based on `initialSelected` only + const { selectedOptions, unselectedOptions } = useMemo(() => { + const sel: typeof options = []; + const unsel: typeof options = []; + for (const o of options) { + if (o.initialSelected) sel.push(o); + else unsel.push(o); + } + return { selectedOptions: sel, unselectedOptions: unsel }; + }, [options]); + + return ( + <Command loop> + <CommandInput autoFocus placeholder={t('search', locale)} /> + <CommandEmpty>{t('noresults', locale)}</CommandEmpty> + <CommandList className="max-h-fit"> + <CommandGroup className={cn(selectedOptions.length === 0 && 'hidden')}> + {selectedOptions.map((option) => ( + <OptionItem key={option.value} option={option} onToggle={handleToggle} /> + ))} + </CommandGroup> + <CommandSeparator /> + <CommandGroup className={cn(unselectedOptions.length === 0 && 'hidden')}> + {unselectedOptions.map((option) => ( + <OptionItem key={option.value} option={option} onToggle={handleToggle} /> + ))} + </CommandGroup> + </CommandList> + </Command> + ); +} + +export function FilterValueMultiOptionController<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueControllerProps<TData, 'multiOption'>) { + // Compute initial options once per mount + const initialOptions = useMemo(() => { + const counts = column.getFacetedUniqueValues(); + return column.getOptions().map((o) => { + const selected = filter?.values.includes(o.value); + return { + ...o, + selected, + initialSelected: selected, + count: counts?.get(o.value) ?? 0, + }; + }); + }, []); + + const [options, setOptions] = useState(initialOptions); + + // Update selected state when filter values change + useEffect(() => { + setOptions((prev) => prev.map((o) => ({ ...o, selected: filter?.values.includes(o.value) }))); + }, [filter?.values]); + + const handleToggle = useCallback( + (value: string, checked: boolean) => { + if (checked) actions.addFilterValue(column, [value]); + else actions.removeFilterValue(column, [value]); + }, + [actions, column], + ); + + // Derive groups based on `initialSelected` only + const { selectedOptions, unselectedOptions } = useMemo(() => { + const sel: typeof options = []; + const unsel: typeof options = []; + for (const o of options) { + if (o.initialSelected) sel.push(o); + else unsel.push(o); + } + return { selectedOptions: sel, unselectedOptions: unsel }; + }, [options]); + + return ( + <Command loop> + <CommandInput autoFocus placeholder={t('search', locale)} /> + <CommandEmpty>{t('noresults', locale)}</CommandEmpty> + <CommandList> + <CommandGroup className={cn(selectedOptions.length === 0 && 'hidden')}> + {selectedOptions.map((option) => ( + <OptionItem key={option.value} option={option} onToggle={handleToggle} /> + ))} + </CommandGroup> + <CommandSeparator /> + <CommandGroup className={cn(unselectedOptions.length === 0 && 'hidden')}> + {unselectedOptions.map((option) => ( + <OptionItem key={option.value} option={option} onToggle={handleToggle} /> + ))} + </CommandGroup> + </CommandList> + </Command> + ); +} + +export function FilterValueDateController<TData>({ + filter, + column, + actions, +}: FilterValueControllerProps<TData, 'date'>) { + const [date, setDate] = useState<DateRange | undefined>({ + from: filter?.values[0] ?? new Date(), + to: filter?.values[1] ?? undefined, + }); + + function changeDateRange(value: DateRange | undefined) { + const start = value?.from; + const end = start && value && value.to && !isEqual(start, value.to) ? value.to : undefined; + + setDate({ from: start, to: end }); + + const isRange = start && end; + const newValues = isRange ? [start, end] : start ? [start] : []; + + actions.setFilterValue(column, newValues); + } + + return ( + <Command> + <CommandList className="max-h-fit"> + <CommandGroup> + <div> + <Calendar + initialFocus + mode="range" + defaultMonth={date?.from} + selected={date} + onSelect={changeDateRange} + numberOfMonths={1} + /> + </div> + </CommandGroup> + </CommandList> + </Command> + ); +} + +export function FilterValueTextController<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueControllerProps<TData, 'text'>) { + const changeText = (value: string | number) => { + actions.setFilterValue(column, [String(value)]); + }; + + return ( + <Command> + <CommandList className="max-h-fit"> + <CommandGroup> + <CommandItem> + <DebouncedInput + placeholder={t('search', locale)} + autoFocus + value={filter?.values[0] ?? ''} + onChange={changeText} + /> + </CommandItem> + </CommandGroup> + </CommandList> + </Command> + ); +} + +export function FilterValueNumberController<TData>({ + filter, + column, + actions, + locale = 'en', +}: FilterValueControllerProps<TData, 'number'>) { + const minMax = useMemo(() => column.getFacetedMinMaxValues(), [column]); + const [sliderMin, sliderMax] = [minMax ? minMax[0] : 0, minMax ? minMax[1] : 0]; + + // Local state for values + const [values, setValues] = useState(filter?.values ?? [0, 0]); + + // Sync with parent filter changes + useEffect(() => { + if (filter?.values && filter.values.length === values.length && filter.values.every((v, i) => v === values[i])) { + setValues(filter.values); + } + }, [filter?.values, values]); + + const isNumberRange = + // filter && values.length === 2 + filter && numberFilterOperators[filter.operator].target === 'multiple'; + + const setFilterOperatorDebounced = useDebounceCallback(actions.setFilterOperator, 500); + const setFilterValueDebounced = useDebounceCallback(actions.setFilterValue, 500); + + const changeNumber = (value: number[]) => { + setValues(value); + setFilterValueDebounced(column as any, value); + }; + + const changeMinNumber = (value: number) => { + const newValues = createNumberRange([value, values[1]]); + setValues(newValues); + setFilterValueDebounced(column as any, newValues); + }; + + const changeMaxNumber = (value: number) => { + const newValues = createNumberRange([values[0], value]); + setValues(newValues); + setFilterValueDebounced(column as any, newValues); + }; + + const changeType = useCallback( + (type: 'single' | 'range') => { + let newValues: number[] = []; + if (type === 'single') + newValues = [values[0]]; // Keep the first value for single mode + else if (minMax) { + const value = values[0]; + newValues = + value - minMax[0] < minMax[1] - value + ? createNumberRange([value, minMax[1]]) + : createNumberRange([minMax[0], value]); + } else newValues = createNumberRange([values[0], values[1] ?? 0]); + + const newOperator = type === 'single' ? 'is' : 'is between'; + + // Update local state + setValues(newValues); + + // Cancel in-flight debounced calls to prevent flicker/race conditions + setFilterOperatorDebounced.cancel(); + setFilterValueDebounced.cancel(); + + // Update global filter state atomically + actions.setFilterOperator(column.id, newOperator); + actions.setFilterValue(column, newValues); + }, + [values, column, actions, minMax], + ); + + return ( + <Command> + <CommandList className="w-[300px] px-2 py-2"> + <CommandGroup> + <div className="flex flex-col w-full"> + <Tabs value={isNumberRange ? 'range' : 'single'} onValueChange={(v) => changeType(v as 'single' | 'range')}> + <TabsList className="w-full *:text-xs"> + <TabsTrigger value="single">{t('single', locale)}</TabsTrigger> + <TabsTrigger value="range">{t('range', locale)}</TabsTrigger> + </TabsList> + <TabsContent value="single" className="flex flex-col gap-4 mt-4"> + {minMax && ( + <Slider + value={[values[0]]} + onValueChange={(value) => changeNumber(value)} + min={sliderMin} + max={sliderMax} + step={1} + aria-orientation="horizontal" + /> + )} + <div className="flex items-center gap-2"> + <span className="text-xs font-medium">{t('value', locale)}</span> + <DebouncedInput + id="single" + type="number" + value={values[0].toString()} // Use values[0] directly + onChange={(v) => changeNumber([Number(v)])} + /> + </div> + </TabsContent> + <TabsContent value="range" className="flex flex-col gap-4 mt-4"> + {minMax && ( + <Slider + value={values} // Use values directly + onValueChange={changeNumber} + min={sliderMin} + max={sliderMax} + step={1} + aria-orientation="horizontal" + /> + )} + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center gap-2"> + <span className="text-xs font-medium">{t('min', locale)}</span> + <DebouncedInput type="number" value={values[0]} onChange={(v) => changeMinNumber(Number(v))} /> + </div> + <div className="flex items-center gap-2"> + <span className="text-xs font-medium">{t('max', locale)}</span> + <DebouncedInput type="number" value={values[1]} onChange={(v) => changeMaxNumber(Number(v))} /> + </div> + </div> + </TabsContent> + </Tabs> + </div> + </CommandGroup> + </CommandList> + </Command> + ); +} diff --git a/packages/components/src/ui/data-table-filter/core/filters.ts b/packages/components/src/ui/data-table-filter/core/filters.ts new file mode 100644 index 00000000..171b44aa --- /dev/null +++ b/packages/components/src/ui/data-table-filter/core/filters.ts @@ -0,0 +1,411 @@ +import { isAnyOf, uniq } from '../lib/array'; +import { isColumnOptionArray } from '../lib/helpers'; +import { memo } from '../lib/memo'; +import type { + Column, + ColumnConfig, + ColumnDataType, + ColumnOption, + ElementType, + FilterStrategy, + Nullable, + TAccessorFn, + TOrderFn, + TTransformOptionFn, +} from './types'; + +class ColumnConfigBuilder< + TData, + TType extends ColumnDataType = ColumnDataType, + TVal = unknown, + TId extends string = string, // Add TId generic +> { + private config: Partial<ColumnConfig<TData, TType, TVal, TId>>; + + constructor(type: TType) { + this.config = { type } as Partial<ColumnConfig<TData, TType, TVal, TId>>; + } + + private clone(): ColumnConfigBuilder<TData, TType, TVal, TId> { + const newInstance = new ColumnConfigBuilder<TData, TType, TVal, TId>(this.config.type as TType); + newInstance.config = { ...this.config }; + return newInstance; + } + + id<TNewId extends string>(value: TNewId): ColumnConfigBuilder<TData, TType, TVal, TNewId> { + const newInstance = this.clone(); + (newInstance.config as Partial<ColumnConfig<TData, TType, TVal, TNewId>>).id = value; + return newInstance as unknown as ColumnConfigBuilder<TData, TType, TVal, TNewId>; + } + + accessor<TNewVal>(accessor: TAccessorFn<TData, TNewVal>): ColumnConfigBuilder<TData, TType, TNewVal, TId> { + const newInstance = this.clone(); + (newInstance.config as Partial<ColumnConfig<TData, TType, TNewVal, TId>>).accessor = accessor; + return newInstance as unknown as ColumnConfigBuilder<TData, TType, TNewVal, TId>; + } + + displayName(value: string): ColumnConfigBuilder<TData, TType, TVal, TId> { + const newInstance = this.clone(); + newInstance.config.displayName = value; + return newInstance; + } + + // biome-ignore lint/suspicious/noExplicitAny: any allows for flexibility + icon(value: ElementType<any>): ColumnConfigBuilder<TData, TType, TVal, TId> { + const newInstance = this.clone(); + newInstance.config.icon = value; + return newInstance; + } + + min(value: number): ColumnConfigBuilder<TData, 'number', TVal, TId> { + if (this.config.type !== 'number') { + throw new Error('min() is only applicable to number columns'); + } + const newInstance = this.clone(); + (newInstance.config as Partial<ColumnConfig<TData, 'number', TVal, TId>>).min = value; + return newInstance as unknown as ColumnConfigBuilder<TData, 'number', TVal, TId>; + } + + max(value: number): ColumnConfigBuilder<TData, 'number', TVal, TId> { + if (this.config.type !== 'number') { + throw new Error('max() is only applicable to number columns'); + } + const newInstance = this.clone(); + (newInstance.config as Partial<ColumnConfig<TData, 'number', TVal, TId>>).max = value; + return newInstance as unknown as ColumnConfigBuilder<TData, 'number', TVal, TId>; + } + + options(value: ColumnOption[]): ColumnConfigBuilder<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId> { + if (!isAnyOf(this.config.type, ['option', 'multiOption'])) { + throw new Error('options() is only applicable to option or multiOption columns'); + } + const newInstance = this.clone(); + (newInstance.config as Partial<ColumnConfig<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId>>).options = + value; + return newInstance as unknown as ColumnConfigBuilder<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId>; + } + + transformOptionFn( + fn: TTransformOptionFn<TVal>, + ): ColumnConfigBuilder<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId> { + if (!isAnyOf(this.config.type, ['option', 'multiOption'])) { + throw new Error('transformOptionFn() is only applicable to option or multiOption columns'); + } + const newInstance = this.clone(); + ( + newInstance.config as Partial<ColumnConfig<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId>> + ).transformOptionFn = fn; + return newInstance as unknown as ColumnConfigBuilder<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId>; + } + + orderFn(fn: TOrderFn<TVal>): ColumnConfigBuilder<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId> { + if (!isAnyOf(this.config.type, ['option', 'multiOption'])) { + throw new Error('orderFn() is only applicable to option or multiOption columns'); + } + const newInstance = this.clone(); + (newInstance.config as Partial<ColumnConfig<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId>>).orderFn = + fn; + return newInstance as unknown as ColumnConfigBuilder<TData, Extract<TType, 'option' | 'multiOption'>, TVal, TId>; + } + + build(): ColumnConfig<TData, TType, TVal, TId> { + if (!this.config.id) throw new Error('id is required'); + if (!this.config.accessor) throw new Error('accessor is required'); + if (!this.config.displayName) throw new Error('displayName is required'); + if (!this.config.icon) throw new Error('icon is required'); + return this.config as ColumnConfig<TData, TType, TVal, TId>; + } +} + +// Update the helper interface +interface FluentColumnConfigHelper<TData> { + text: () => ColumnConfigBuilder<TData, 'text', string>; + number: () => ColumnConfigBuilder<TData, 'number', number>; + date: () => ColumnConfigBuilder<TData, 'date', Date>; + option: () => ColumnConfigBuilder<TData, 'option', string>; + multiOption: () => ColumnConfigBuilder<TData, 'multiOption', string[]>; +} + +// Factory function remains mostly the same +export function createColumnConfigHelper<TData>(): FluentColumnConfigHelper<TData> { + return { + text: () => new ColumnConfigBuilder<TData, 'text', string>('text'), + number: () => new ColumnConfigBuilder<TData, 'number', number>('number'), + date: () => new ColumnConfigBuilder<TData, 'date', Date>('date'), + option: () => new ColumnConfigBuilder<TData, 'option', string>('option'), + multiOption: () => new ColumnConfigBuilder<TData, 'multiOption', string[]>('multiOption'), + }; +} + +export function getColumnOptions<TData, TType extends ColumnDataType, TVal>( + column: ColumnConfig<TData, TType, TVal>, + data: TData[], + strategy: FilterStrategy, +): ColumnOption[] { + if (!isAnyOf(column.type, ['option', 'multiOption'])) { + console.warn('Column options can only be retrieved for option and multiOption columns'); + return []; + } + + if (strategy === 'server' && !column.options) { + throw new Error('column options are required for server-side filtering'); + } + + if (column.options) { + return column.options; + } + + const filtered = data.flatMap(column.accessor).filter((v): v is NonNullable<TVal> => v !== undefined && v !== null); + + let models = uniq(filtered); + + if (column.orderFn) { + const orderFunction = column.orderFn; + models = models.sort((m1, m2) => + orderFunction(m1 as ElementType<NonNullable<TVal>>, m2 as ElementType<NonNullable<TVal>>), + ); + } + + if (column.transformOptionFn) { + const transformFunction = column.transformOptionFn; + // Memoize transformOptionFn calls + const memoizedTransform = memo( + () => [models], + (deps) => deps[0].map((m) => transformFunction(m as ElementType<NonNullable<TVal>>)), + { key: `transform-${column.id}` }, + ); + return memoizedTransform(); + } + + if (isColumnOptionArray(models)) return models; + + throw new Error( + `[data-table-filter] [${column.id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type`, + ); +} + +export function getColumnValues<TData, TType extends ColumnDataType, TVal>( + column: ColumnConfig<TData, TType, TVal>, + data: TData[], +) { + // Memoize accessor calls + const memoizedAccessor = memo( + () => [data], + (deps) => + deps[0] + .flatMap(column.accessor) + .filter((v): v is NonNullable<TVal> => v !== undefined && v !== null) as ElementType<NonNullable<TVal>>[], + { key: `accessor-${column.id}` }, + ); + + const raw = memoizedAccessor(); + + if (!isAnyOf(column.type, ['option', 'multiOption'])) { + return raw; + } + + if (column.options) { + return raw + .map((v) => column.options?.find((o) => o.value === v)?.value) + .filter((v) => v !== undefined && v !== null); + } + + if (column.transformOptionFn) { + const memoizedTransform = memo( + () => [raw], + (deps) => + deps[0].map((v) => column.transformOptionFn && (column.transformOptionFn(v) as ElementType<NonNullable<TVal>>)), + { key: `transform-values-${column.id}` }, + ); + return memoizedTransform(); + } + + if (isColumnOptionArray(raw)) { + return raw; + } + + throw new Error( + `[data-table-filter] [${column.id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type`, + ); +} + +export function getFacetedUniqueValues<TData, TType extends ColumnDataType, TVal>( + column: ColumnConfig<TData, TType, TVal>, + values: string[] | ColumnOption[], + strategy: FilterStrategy, +): Map<string, number> | undefined { + if (!isAnyOf(column.type, ['option', 'multiOption'])) { + console.warn('Faceted unique values can only be retrieved for option and multiOption columns'); + return new Map<string, number>(); + } + + if (strategy === 'server') { + return column.facetedOptions; + } + + const acc = new Map<string, number>(); + + if (isColumnOptionArray(values)) { + for (const option of values) { + const curr = acc.get(option.value) ?? 0; + acc.set(option.value, curr + 1); + } + } else { + for (const option of values) { + const curr = acc.get(option as string) ?? 0; + acc.set(option as string, curr + 1); + } + } + + return acc; +} + +export function getFacetedMinMaxValues<TData, TType extends ColumnDataType, TVal>( + column: ColumnConfig<TData, TType, TVal>, + data: TData[], + strategy: FilterStrategy, +): [number, number] | undefined { + if (column.type !== 'number') return undefined; // Only applicable to number columns + + if (typeof column.min === 'number' && typeof column.max === 'number') { + return [column.min, column.max]; + } + + if (strategy === 'server') { + return undefined; + } + + const values = data + .flatMap((row) => column.accessor(row) as Nullable<number>) + .filter((v): v is number => typeof v === 'number' && !Number.isNaN(v)); + + if (values.length === 0) { + return [0, 0]; // Fallback to config or reasonable defaults + } + + const min = Math.min(...values); + const max = Math.max(...values); + + return [min, max]; +} + +export function createColumns<TData>( + data: TData[], + // biome-ignore lint/suspicious/noExplicitAny: any allows for flexibility + columnConfigs: readonly ColumnConfig<TData, any, any, any>[], + strategy: FilterStrategy, +): Column<TData>[] { + return columnConfigs.map((columnConfig) => { + const getOptions: () => ColumnOption[] = memo( + () => [data, strategy, columnConfig.options], + ([data, strategy]) => getColumnOptions(columnConfig, data as TData[], strategy as FilterStrategy), + { key: `options-${columnConfig.id}` }, + ); + + const getValues: () => ElementType<NonNullable<unknown>>[] = memo( + () => [data, strategy], + () => (strategy === 'client' ? getColumnValues(columnConfig, data) : []), + { key: `values-${columnConfig.id}` }, + ); + + const getUniqueValues: () => Map<string, number> | undefined = memo( + () => [getValues(), strategy], + ([values, strategy]) => + getFacetedUniqueValues(columnConfig, values as ColumnOption[], strategy as FilterStrategy), + { key: `faceted-${columnConfig.id}` }, + ); + + const getMinMaxValues: () => [number, number] | undefined = memo( + () => [data, strategy], + () => getFacetedMinMaxValues(columnConfig, data, strategy), + { key: `minmax-${columnConfig.id}` }, + ); + + // Create the Column instance + const column: Column<TData> = { + ...columnConfig, + getOptions, + getValues, + getFacetedUniqueValues: getUniqueValues, + getFacetedMinMaxValues: getMinMaxValues, + // Prefetch methods will be added below + prefetchOptions: async () => { + // Placeholder, defined below + }, + prefetchValues: async () => { + // Placeholder, defined below + }, + prefetchFacetedUniqueValues: async () => { + // Placeholder, defined below + }, + prefetchFacetedMinMaxValues: async () => { + // Placeholder, defined below + }, + _prefetchedOptionsCache: null, // Initialize private cache + _prefetchedValuesCache: null, + _prefetchedFacetedUniqueValuesCache: null, + _prefetchedFacetedMinMaxValuesCache: null, + }; + + if (strategy === 'client') { + // Define prefetch methods with access to the column instance + column.prefetchOptions = async (): Promise<void> => { + if (!column._prefetchedOptionsCache) { + await new Promise((resolve) => + setTimeout(() => { + const options = getOptions(); + column._prefetchedOptionsCache = options; + // console.log(`Prefetched options for ${columnConfig.id}`) + resolve(undefined); + }, 0), + ); + } + }; + + column.prefetchValues = async (): Promise<void> => { + if (!column._prefetchedValuesCache) { + await new Promise((resolve) => + setTimeout(() => { + const values = getValues(); + column._prefetchedValuesCache = values; + // console.log(`Prefetched values for ${columnConfig.id}`) + resolve(undefined); + }, 0), + ); + } + }; + + column.prefetchFacetedUniqueValues = async (): Promise<void> => { + if (!column._prefetchedFacetedUniqueValuesCache) { + await new Promise((resolve) => + setTimeout(() => { + const facetedMap = getUniqueValues(); + column._prefetchedFacetedUniqueValuesCache = facetedMap ?? null; + // console.log( + // `Prefetched faceted unique values for ${columnConfig.id}`, + // ) + resolve(undefined); + }, 0), + ); + } + }; + + column.prefetchFacetedMinMaxValues = async (): Promise<void> => { + if (!column._prefetchedFacetedMinMaxValuesCache) { + await new Promise((resolve) => + setTimeout(() => { + const value = getMinMaxValues(); + column._prefetchedFacetedMinMaxValuesCache = value ?? null; + // console.log( + // `Prefetched faceted min/max values for ${columnConfig.id}`, + // ) + resolve(undefined); + }, 0), + ); + } + }; + } + + return column; + }); +} diff --git a/packages/components/src/ui/data-table-filter/core/operators.ts b/packages/components/src/ui/data-table-filter/core/operators.ts new file mode 100644 index 00000000..7f5b39a7 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/core/operators.ts @@ -0,0 +1,407 @@ +import { type Locale, t } from '../lib/i18n' +import type { + ColumnDataType, + FilterDetails, + FilterOperatorTarget, + FilterOperators, + FilterTypeOperatorDetails, + FilterValues, +} from './types' + +export const DEFAULT_OPERATORS: Record< + ColumnDataType, + Record<FilterOperatorTarget, FilterOperators[ColumnDataType]> +> = { + text: { + single: 'contains', + multiple: 'contains', + }, + number: { + single: 'is', + multiple: 'is between', + }, + date: { + single: 'is', + multiple: 'is between', + }, + option: { + single: 'is', + multiple: 'is any of', + }, + multiOption: { + single: 'include', + multiple: 'include any of', + }, +} + +/* Details for all the filter operators for option data type */ +export const optionFilterOperators = { + is: { + key: 'filters.option.is', + value: 'is', + target: 'single', + singularOf: 'is any of', + relativeOf: 'is not', + isNegated: false, + negation: 'is not', + }, + 'is not': { + key: 'filters.option.isNot', + value: 'is not', + target: 'single', + singularOf: 'is none of', + relativeOf: 'is', + isNegated: true, + negationOf: 'is', + }, + 'is any of': { + key: 'filters.option.isAnyOf', + value: 'is any of', + target: 'multiple', + pluralOf: 'is', + relativeOf: 'is none of', + isNegated: false, + negation: 'is none of', + }, + 'is none of': { + key: 'filters.option.isNoneOf', + value: 'is none of', + target: 'multiple', + pluralOf: 'is not', + relativeOf: 'is any of', + isNegated: true, + negationOf: 'is any of', + }, +} as const satisfies FilterDetails<'option'> + +/* Details for all the filter operators for multi-option data type */ +export const multiOptionFilterOperators = { + include: { + key: 'filters.multiOption.include', + value: 'include', + target: 'single', + singularOf: 'include any of', + relativeOf: 'exclude', + isNegated: false, + negation: 'exclude', + }, + exclude: { + key: 'filters.multiOption.exclude', + value: 'exclude', + target: 'single', + singularOf: 'exclude if any of', + relativeOf: 'include', + isNegated: true, + negationOf: 'include', + }, + 'include any of': { + key: 'filters.multiOption.includeAnyOf', + value: 'include any of', + target: 'multiple', + pluralOf: 'include', + relativeOf: ['exclude if all', 'include all of', 'exclude if any of'], + isNegated: false, + negation: 'exclude if all', + }, + 'exclude if all': { + key: 'filters.multiOption.excludeIfAll', + value: 'exclude if all', + target: 'multiple', + pluralOf: 'exclude', + relativeOf: ['include any of', 'include all of', 'exclude if any of'], + isNegated: true, + negationOf: 'include any of', + }, + 'include all of': { + key: 'filters.multiOption.includeAllOf', + value: 'include all of', + target: 'multiple', + pluralOf: 'include', + relativeOf: ['include any of', 'exclude if all', 'exclude if any of'], + isNegated: false, + negation: 'exclude if any of', + }, + 'exclude if any of': { + key: 'filters.multiOption.excludeIfAnyOf', + value: 'exclude if any of', + target: 'multiple', + pluralOf: 'exclude', + relativeOf: ['include any of', 'exclude if all', 'include all of'], + isNegated: true, + negationOf: 'include all of', + }, +} as const satisfies FilterDetails<'multiOption'> + +/* Details for all the filter operators for date data type */ +export const dateFilterOperators = { + is: { + key: 'filters.date.is', + value: 'is', + target: 'single', + singularOf: 'is between', + relativeOf: 'is after', + isNegated: false, + negation: 'is before', + }, + 'is not': { + key: 'filters.date.isNot', + value: 'is not', + target: 'single', + singularOf: 'is not between', + relativeOf: [ + 'is', + 'is before', + 'is on or after', + 'is after', + 'is on or before', + ], + isNegated: true, + negationOf: 'is', + }, + 'is before': { + key: 'filters.date.isBefore', + value: 'is before', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is on or after', + 'is after', + 'is on or before', + ], + isNegated: false, + negation: 'is on or after', + }, + 'is on or after': { + key: 'filters.date.isOnOrAfter', + value: 'is on or after', + target: 'single', + singularOf: 'is between', + relativeOf: ['is', 'is not', 'is before', 'is after', 'is on or before'], + isNegated: false, + negation: 'is before', + }, + 'is after': { + key: 'filters.date.isAfter', + value: 'is after', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is before', + 'is on or after', + 'is on or before', + ], + isNegated: false, + negation: 'is on or before', + }, + 'is on or before': { + key: 'filters.date.isOnOrBefore', + value: 'is on or before', + target: 'single', + singularOf: 'is between', + relativeOf: ['is', 'is not', 'is after', 'is on or after', 'is before'], + isNegated: false, + negation: 'is after', + }, + 'is between': { + key: 'filters.date.isBetween', + value: 'is between', + target: 'multiple', + pluralOf: 'is', + relativeOf: 'is not between', + isNegated: false, + negation: 'is not between', + }, + 'is not between': { + key: 'filters.date.isNotBetween', + value: 'is not between', + target: 'multiple', + pluralOf: 'is not', + relativeOf: 'is between', + isNegated: true, + negationOf: 'is between', + }, +} as const satisfies FilterDetails<'date'> + +/* Details for all the filter operators for text data type */ +export const textFilterOperators = { + contains: { + key: 'filters.text.contains', + value: 'contains', + target: 'single', + relativeOf: 'does not contain', + isNegated: false, + negation: 'does not contain', + }, + 'does not contain': { + key: 'filters.text.doesNotContain', + value: 'does not contain', + target: 'single', + relativeOf: 'contains', + isNegated: true, + negationOf: 'contains', + }, +} as const satisfies FilterDetails<'text'> + +/* Details for all the filter operators for number data type */ +export const numberFilterOperators = { + is: { + key: 'filters.number.is', + value: 'is', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is not', + 'is greater than', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is not', + }, + 'is not': { + key: 'filters.number.isNot', + value: 'is not', + target: 'single', + singularOf: 'is not between', + relativeOf: [ + 'is', + 'is greater than', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: true, + negationOf: 'is', + }, + 'is greater than': { + key: 'filters.number.greaterThan', + value: 'is greater than', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is less than or equal to', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is less than or equal to', + }, + 'is greater than or equal to': { + key: 'filters.number.greaterThanOrEqual', + value: 'is greater than or equal to', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than or equal to', + 'is less than', + ], + isNegated: false, + negation: 'is less than or equal to', + }, + 'is less than': { + key: 'filters.number.lessThan', + value: 'is less than', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than or equal to', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is greater than', + }, + 'is less than or equal to': { + key: 'filters.number.lessThanOrEqual', + value: 'is less than or equal to', + target: 'single', + singularOf: 'is between', + relativeOf: [ + 'is', + 'is not', + 'is greater than', + 'is less than', + 'is greater than or equal to', + ], + isNegated: false, + negation: 'is greater than or equal to', + }, + 'is between': { + key: 'filters.number.isBetween', + value: 'is between', + target: 'multiple', + pluralOf: 'is', + relativeOf: 'is not between', + isNegated: false, + negation: 'is not between', + }, + 'is not between': { + key: 'filters.number.isNotBetween', + value: 'is not between', + target: 'multiple', + pluralOf: 'is not', + relativeOf: 'is between', + isNegated: true, + negationOf: 'is between', + }, +} as const satisfies FilterDetails<'number'> + +export const filterTypeOperatorDetails: FilterTypeOperatorDetails = { + text: textFilterOperators, + number: numberFilterOperators, + date: dateFilterOperators, + option: optionFilterOperators, + multiOption: multiOptionFilterOperators, +} + +/* + * + * Determines the new operator for a filter based on the current operator, old and new filter values. + * + * This handles cases where the filter values have transitioned from a single value to multiple values (or vice versa), + * and the current operator needs to be transitioned to its plural form (or singular form). + * + * For example, if the current operator is 'is', and the new filter values have a length of 2, the + * new operator would be 'is any of'. + * + */ +export function determineNewOperator<TType extends ColumnDataType>( + type: TType, + oldVals: FilterValues<TType>, + nextVals: FilterValues<TType>, + currentOperator: FilterOperators[TType], +): FilterOperators[TType] { + const a = + Array.isArray(oldVals) && Array.isArray(oldVals[0]) + ? oldVals[0].length + : oldVals.length + const b = + Array.isArray(nextVals) && Array.isArray(nextVals[0]) + ? nextVals[0].length + : nextVals.length + + // If filter size has not transitioned from single to multiple (or vice versa) + // or is unchanged, return the current operator. + if (a === b || (a >= 2 && b >= 2) || (a <= 1 && b <= 1)) + return currentOperator + + const opDetails = filterTypeOperatorDetails[type][currentOperator] + + // Handle transition from single to multiple filter values. + if (a < b && b >= 2) return opDetails.singularOf ?? currentOperator + // Handle transition from multiple to single filter values. + if (a > b && b <= 1) return opDetails.pluralOf ?? currentOperator + return currentOperator +} diff --git a/packages/components/src/ui/data-table-filter/core/types.ts b/packages/components/src/ui/data-table-filter/core/types.ts new file mode 100644 index 00000000..a49a72c2 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/core/types.ts @@ -0,0 +1,352 @@ +import type { LucideIcon } from 'lucide-react' + +/* + * # GENERAL NOTES: + * + * ## GENERICS: + * + * TData is the shape of a single row in your data table. + * TVal is the shape of the underlying value for a column. + * TType is the type (kind) of the column. + * + */ + +export type ElementType<T> = T extends (infer U)[] ? U : T + +export type Nullable<T> = T | null | undefined + +/* + * The model of a column option. + * Used for representing underlying column values of type `option` or `multiOption`. + */ +export interface ColumnOption { + /* The label to display for the option. */ + label: string + /* The internal value of the option. */ + value: string + /* An optional icon to display next to the label. */ + icon?: React.ReactElement | React.ElementType +} + +export interface ColumnOptionExtended extends ColumnOption { + selected?: boolean + count?: number +} + +/* + * Represents the data type (kind) of a column. + */ +export type ColumnDataType = + /* The column value is a string that should be searchable. */ + | 'text' + | 'number' + | 'date' + /* The column value can be a single value from a list of options. */ + | 'option' + /* The column value can be zero or more values from a list of options. */ + | 'multiOption' + +/* + * Represents the data type (kind) of option and multi-option columns. + */ +export type OptionBasedColumnDataType = Extract< + ColumnDataType, + 'option' | 'multiOption' +> + +/* + * Maps a ColumnDataType to it's primitive type (i.e. string, number, etc.). + */ +export type ColumnDataNativeMap = { + text: string + number: number + date: Date + option: string + multiOption: string[] +} + +/* + * Represents the value of a column filter. + * Contigent on the filtered column's data type. + */ +export type FilterValues<T extends ColumnDataType> = Array< + ElementType<ColumnDataNativeMap[T]> +> + +/* + * An accessor function for a column's data. + * Uses the original row data as an argument. + */ +export type TAccessorFn<TData, TVal = unknown> = (data: TData) => TVal + +/* + * Used by `option` and `multiOption` columns. + * Transforms the underlying column value into a valid ColumnOption. + */ +export type TTransformOptionFn<TVal = unknown> = ( + value: ElementType<NonNullable<TVal>>, +) => ColumnOption + +/* + * Used by `option` and `multiOption` columns. + * A custom ordering function when sorting a column's options. + */ +export type TOrderFn<TVal = unknown> = ( + a: ElementType<NonNullable<TVal>>, + b: ElementType<NonNullable<TVal>>, +) => number + +/* + * The configuration for a column. + */ +export type ColumnConfig< + TData, + TType extends ColumnDataType = any, + TVal = unknown, + TId extends string = string, +> = { + id: TId + accessor: TAccessorFn<TData, TVal> + displayName: string + icon: LucideIcon + type: TType + options?: TType extends OptionBasedColumnDataType ? ColumnOption[] : never + facetedOptions?: TType extends OptionBasedColumnDataType + ? Map<string, number> + : never + min?: TType extends 'number' ? number : never + max?: TType extends 'number' ? number : never + transformOptionFn?: TType extends OptionBasedColumnDataType + ? TTransformOptionFn<TVal> + : never + orderFn?: TType extends OptionBasedColumnDataType ? TOrderFn<TVal> : never +} + +export type OptionColumnId<T> = T extends ColumnConfig< + infer TData, + 'option' | 'multiOption', + infer TVal, + infer TId +> + ? TId + : never + +export type OptionColumnIds< + T extends ReadonlyArray<ColumnConfig<any, any, any, any>>, +> = { + [K in keyof T]: OptionColumnId<T[K]> +}[number] + +export type NumberColumnId<T> = T extends ColumnConfig< + infer TData, + 'number', + infer TVal, + infer TId +> + ? TId + : never + +export type NumberColumnIds< + T extends ReadonlyArray<ColumnConfig<any, any, any, any>>, +> = { + [K in keyof T]: NumberColumnId<T[K]> +}[number] + +/* + * Describes a helper function for creating column configurations. + */ +export type ColumnConfigHelper<TData> = { + accessor: < + TAccessor extends TAccessorFn<TData>, + TType extends ColumnDataType, + TVal extends ReturnType<TAccessor>, + >( + accessor: TAccessor, + config?: Omit<ColumnConfig<TData, TType, TVal>, 'accessor'>, + ) => ColumnConfig<TData, TType, unknown> +} + +export type DataTableFilterConfig<TData> = { + data: TData[] + columns: ColumnConfig<TData>[] +} + +export type ColumnProperties<TData, TVal> = { + getOptions: () => ColumnOption[] + getValues: () => ElementType<NonNullable<TVal>>[] + getFacetedUniqueValues: () => Map<string, number> | undefined + getFacetedMinMaxValues: () => [number, number] | undefined + prefetchOptions: () => Promise<void> // Prefetch options + prefetchValues: () => Promise<void> // Prefetch values + prefetchFacetedUniqueValues: () => Promise<void> // Prefetch faceted unique values + prefetchFacetedMinMaxValues: () => Promise<void> // Prefetch faceted min/max values +} + +export type ColumnPrivateProperties<TData, TVal> = { + _prefetchedOptionsCache: ColumnOption[] | null + _prefetchedValuesCache: ElementType<NonNullable<TVal>>[] | null + _prefetchedFacetedUniqueValuesCache: Map<string, number> | null + _prefetchedFacetedMinMaxValuesCache: [number, number] | null +} + +export type Column< + TData, + TType extends ColumnDataType = any, + TVal = unknown, +> = ColumnConfig<TData, TType, TVal> & + ColumnProperties<TData, TVal> & + ColumnPrivateProperties<TData, TVal> + +/* + * Describes the available actions on column filters. + * Includes both column-specific and global actions, ultimately acting on the column filters. + */ +export interface DataTableFilterActions { + addFilterValue: <TData, TType extends OptionBasedColumnDataType>( + column: Column<TData, TType>, + values: FilterModel<TType>['values'], + ) => void + + removeFilterValue: <TData, TType extends OptionBasedColumnDataType>( + column: Column<TData, TType>, + value: FilterModel<TType>['values'], + ) => void + + setFilterValue: <TData, TType extends ColumnDataType>( + column: Column<TData, TType>, + values: FilterModel<TType>['values'], + ) => void + + setFilterOperator: <TType extends ColumnDataType>( + columnId: string, + operator: FilterModel<TType>['operator'], + ) => void + + removeFilter: (columnId: string) => void + + removeAllFilters: () => void +} + +export type FilterStrategy = 'client' | 'server' + +/* Operators for text data */ +export type TextFilterOperator = 'contains' | 'does not contain' + +/* Operators for number data */ +export type NumberFilterOperator = + | 'is' + | 'is not' + | 'is less than' + | 'is greater than or equal to' + | 'is greater than' + | 'is less than or equal to' + | 'is between' + | 'is not between' + +/* Operators for date data */ +export type DateFilterOperator = + | 'is' + | 'is not' + | 'is before' + | 'is on or after' + | 'is after' + | 'is on or before' + | 'is between' + | 'is not between' + +/* Operators for option data */ +export type OptionFilterOperator = 'is' | 'is not' | 'is any of' | 'is none of' + +/* Operators for multi-option data */ +export type MultiOptionFilterOperator = + | 'include' + | 'exclude' + | 'include any of' + | 'include all of' + | 'exclude if any of' + | 'exclude if all' + +/* Maps filter operators to their respective data types */ +export type FilterOperators = { + text: TextFilterOperator + number: NumberFilterOperator + date: DateFilterOperator + option: OptionFilterOperator + multiOption: MultiOptionFilterOperator +} + +/* + * + * FilterValue is a type that represents a filter value for a specific column. + * + * It consists of: + * - Operator: The operator to be used for the filter. + * - Values: An array of values to be used for the filter. + * + */ +export type FilterModel<TType extends ColumnDataType = any> = { + columnId: string + type: TType + operator: FilterOperators[TType] + values: FilterValues<TType> +} + +export type FiltersState = Array<FilterModel> + +/* + * FilterDetails is a type that represents the details of all the filter operators for a specific column data type. + */ +export type FilterDetails<T extends ColumnDataType> = { + [key in FilterOperators[T]]: FilterOperatorDetails<key, T> +} + +export type FilterOperatorTarget = 'single' | 'multiple' + +export type FilterOperatorDetailsBase< + OperatorValue, + T extends ColumnDataType, +> = { + /* The i18n key for the operator. */ + key: string + /* The operator value. Usually the string representation of the operator. */ + value: OperatorValue + /* How much data the operator applies to. */ + target: FilterOperatorTarget + /* The plural form of the operator, if applicable. */ + singularOf?: FilterOperators[T] + /* The singular form of the operator, if applicable. */ + pluralOf?: FilterOperators[T] + /* All related operators. Normally, all the operators which share the same target. */ + relativeOf: FilterOperators[T] | Array<FilterOperators[T]> + /* Whether the operator is negated. */ + isNegated: boolean + /* If the operator is not negated, this provides the negated equivalent. */ + negation?: FilterOperators[T] + /* If the operator is negated, this provides the positive equivalent. */ + negationOf?: FilterOperators[T] +} + +/* + * + * FilterOperatorDetails is a type that provides details about a filter operator for a specific column data type. + * It extends FilterOperatorDetailsBase with additional logic and contraints on the defined properties. + * + */ +export type FilterOperatorDetails< + OperatorValue, + T extends ColumnDataType, +> = FilterOperatorDetailsBase<OperatorValue, T> & + ( + | { singularOf?: never; pluralOf?: never } + | { target: 'single'; singularOf: FilterOperators[T]; pluralOf?: never } + | { target: 'multiple'; singularOf?: never; pluralOf: FilterOperators[T] } + ) & + ( + | { isNegated: false; negation: FilterOperators[T]; negationOf?: never } + | { isNegated: true; negation?: never; negationOf: FilterOperators[T] } + ) + +/* Maps column data types to their respective filter operator details */ +export type FilterTypeOperatorDetails = { + [key in ColumnDataType]: FilterDetails<key> +} diff --git a/packages/components/src/ui/data-table-filter/examples/data-table-filter-example.tsx b/packages/components/src/ui/data-table-filter/examples/data-table-filter-example.tsx new file mode 100644 index 00000000..b411da1c --- /dev/null +++ b/packages/components/src/ui/data-table-filter/examples/data-table-filter-example.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { DataTableFilter } from '../components/data-table-filter'; +import { createColumns } from '../core/filters'; +import type { Column, FiltersState } from '../core/types'; +import { useFilterSync } from '../../utils/use-filter-sync'; + +// Example data interface +interface User { + id: string; + name: string; + email: string; + role: 'admin' | 'user' | 'editor'; + status: 'active' | 'inactive' | 'pending'; + createdAt: string; +} + +// Mock API function to fetch data with filters +async function fetchUsers(filters: FiltersState): Promise<{ + data: User[]; + facetedCounts: Record<string, Record<string, number>>; +}> { + // In a real app, this would be an API call with the filters + // For demo purposes, we'll simulate a delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // This would normally come from your API + return { + data: [ + { id: '1', name: 'John Doe', email: 'john@example.com', role: 'admin', status: 'active', createdAt: '2023-01-01' }, + { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'user', status: 'active', createdAt: '2023-01-15' }, + { id: '3', name: 'Bob Johnson', email: 'bob@example.com', role: 'editor', status: 'inactive', createdAt: '2023-02-01' }, + // Add more mock data as needed + ], + facetedCounts: { + role: { admin: 1, user: 1, editor: 1 }, + status: { active: 2, inactive: 1, pending: 0 }, + } + }; +} + +export function DataTableFilterExample() { + // Use the filter sync hook to sync filters with URL query params + const [filters, setFilters] = useFilterSync(); + + // Fetch data with the current filters + const { data, isLoading } = useQuery({ + queryKey: ['users', filters], + queryFn: () => fetchUsers(filters), + placeholderData: (previousData) => previousData, + }); + + // Define column configurations + const columnsConfig = useMemo(() => [ + { + id: 'role', + type: 'option' as const, + displayName: 'Role', + accessor: (user: User) => user.role, + icon: () => null, + options: [ + { value: 'admin', label: 'Admin' }, + { value: 'user', label: 'User' }, + { value: 'editor', label: 'Editor' }, + ], + }, + { + id: 'status', + type: 'option' as const, + displayName: 'Status', + accessor: (user: User) => user.status, + icon: () => null, + options: [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + { value: 'pending', label: 'Pending' }, + ], + }, + ], []); + + // Create columns with faceted counts from the API + const columns = useMemo(() => { + if (!data) return [] as Column<User>[]; + + // Apply faceted counts from the API to the columns + const enhancedConfig = columnsConfig.map(config => { + if (config.type === 'option' && data.facetedCounts[config.id]) { + return { + ...config, + facetedOptions: new Map( + Object.entries(data.facetedCounts[config.id]).map(([key, count]) => [key, count]) + ) + }; + } + return config; + }); + + return createColumns(data.data || [], enhancedConfig, 'client'); + }, [data, columnsConfig]); + + // Create filter actions + const actions = useMemo(() => { + return { + addFilterValue: (column, values) => { + setFilters(prev => { + const filter = prev.find(f => f.columnId === column.id); + if (!filter) { + return [...prev, { + columnId: column.id, + type: column.type, + operator: column.type === 'option' ? 'is any of' : 'contains', + values + }]; + } + return prev.map(f => + f.columnId === column.id + ? { ...f, values: [...new Set([...f.values, ...values])] } + : f + ); + }); + }, + removeFilterValue: (column, values) => { + setFilters(prev => { + const filter = prev.find(f => f.columnId === column.id); + if (!filter) return prev; + + const newValues = filter.values.filter(v => !values.includes(v)); + if (newValues.length === 0) { + return prev.filter(f => f.columnId !== column.id); + } + + return prev.map(f => + f.columnId === column.id + ? { ...f, values: newValues } + : f + ); + }); + }, + setFilterValue: (column, values) => { + setFilters(prev => { + const exists = prev.some(f => f.columnId === column.id); + if (!exists) { + return [...prev, { + columnId: column.id, + type: column.type, + operator: column.type === 'option' ? 'is any of' : 'contains', + values + }]; + } + return prev.map(f => + f.columnId === column.id + ? { ...f, values } + : f + ); + }); + }, + setFilterOperator: (columnId, operator) => { + setFilters(prev => + prev.map(f => + f.columnId === columnId + ? { ...f, operator } + : f + ) + ); + }, + removeFilter: (columnId) => { + setFilters(prev => prev.filter(f => f.columnId !== columnId)); + }, + removeAllFilters: () => { + setFilters([]); + } + }; + }, [setFilters]); + + return ( + <div className="space-y-4"> + <h2 className="text-xl font-bold">Users</h2> + + {/* Data Table Filter Component */} + <DataTableFilter + columns={columns} + filters={filters} + actions={actions} + strategy="client" + /> + + {/* Display the filtered data */} + {isLoading ? ( + <div>Loading...</div> + ) : ( + <div className="border rounded-md"> + <table className="w-full"> + <thead> + <tr className="border-b"> + <th className="p-2 text-left">Name</th> + <th className="p-2 text-left">Email</th> + <th className="p-2 text-left">Role</th> + <th className="p-2 text-left">Status</th> + </tr> + </thead> + <tbody> + {data?.data.map(user => ( + <tr key={user.id} className="border-b"> + <td className="p-2">{user.name}</td> + <td className="p-2">{user.email}</td> + <td className="p-2">{user.role}</td> + <td className="p-2">{user.status}</td> + </tr> + ))} + </tbody> + </table> + </div> + )} + + {/* Display current filter state for debugging */} + <div className="mt-8 p-4 bg-gray-100 rounded-md"> + <h3 className="font-semibold mb-2">Current Filter State:</h3> + <pre className="text-xs overflow-auto">{JSON.stringify(filters, null, 2)}</pre> + </div> + </div> + ); +} + diff --git a/packages/components/src/ui/data-table-filter/examples/simplified-example.tsx b/packages/components/src/ui/data-table-filter/examples/simplified-example.tsx new file mode 100644 index 00000000..b1f8f047 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/examples/simplified-example.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useMemo } from 'react'; +import { DataTableFilter } from '../components/data-table-filter'; +import { useFilteredData } from '../../utils/use-filtered-data'; + +// Example data interface +interface User { + id: string; + name: string; + email: string; + role: 'admin' | 'user' | 'editor'; + status: 'active' | 'inactive' | 'pending'; + createdAt: string; +} + +// Mock initial data +const initialUsers: User[] = [ + { id: '1', name: 'John Doe', email: 'john@example.com', role: 'admin', status: 'active', createdAt: '2023-01-01' }, + { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'user', status: 'active', createdAt: '2023-01-15' }, + { id: '3', name: 'Bob Johnson', email: 'bob@example.com', role: 'editor', status: 'inactive', createdAt: '2023-02-01' }, +]; + +export function SimplifiedExample() { + // Define column configurations + const columnsConfig = useMemo(() => [ + { + id: 'role', + type: 'option' as const, + displayName: 'Role', + accessor: (user: User) => user.role, + icon: () => null, + options: [ + { value: 'admin', label: 'Admin' }, + { value: 'user', label: 'User' }, + { value: 'editor', label: 'Editor' }, + ], + }, + { + id: 'status', + type: 'option' as const, + displayName: 'Status', + accessor: (user: User) => user.status, + icon: () => null, + options: [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + { value: 'pending', label: 'Pending' }, + ], + }, + ], []); + + // Use our custom hook to handle everything + const { + filters, + columns, + actions, + data, + isLoading, + } = useFilteredData<User>({ + endpoint: '/api/users', + columnsConfig, + initialData: initialUsers, + queryOptions: { + // In a real app, you'd set this to true + // For demo purposes, we'll disable actual API calls + enabled: false, + } + }); + + return ( + <div className="space-y-4"> + <h2 className="text-xl font-bold">Users (Simplified Example)</h2> + + {/* Data Table Filter Component */} + <DataTableFilter + columns={columns} + filters={filters} + actions={actions} + strategy="client" + /> + + {/* Display the filtered data */} + {isLoading ? ( + <div>Loading...</div> + ) : ( + <div className="border rounded-md"> + <table className="w-full"> + <thead> + <tr className="border-b"> + <th className="p-2 text-left">Name</th> + <th className="p-2 text-left">Email</th> + <th className="p-2 text-left">Role</th> + <th className="p-2 text-left">Status</th> + </tr> + </thead> + <tbody> + {data.map(user => ( + <tr key={user.id} className="border-b"> + <td className="p-2">{user.name}</td> + <td className="p-2">{user.email}</td> + <td className="p-2">{user.role}</td> + <td className="p-2">{user.status}</td> + </tr> + ))} + </tbody> + </table> + </div> + )} + + {/* Display current filter state for debugging */} + <div className="mt-8 p-4 bg-gray-100 rounded-md"> + <h3 className="font-semibold mb-2">Current Filter State:</h3> + <pre className="text-xs overflow-auto">{JSON.stringify(filters, null, 2)}</pre> + </div> + </div> + ); +} + diff --git a/packages/components/src/ui/data-table-filter/index.tsx b/packages/components/src/ui/data-table-filter/index.tsx new file mode 100644 index 00000000..71a4ea17 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/index.tsx @@ -0,0 +1 @@ +export { DataTableFilter } from './components/data-table-filter'; diff --git a/packages/components/src/ui/data-table-filter/lib/array.ts b/packages/components/src/ui/data-table-filter/lib/array.ts new file mode 100644 index 00000000..0168a7cd --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/array.ts @@ -0,0 +1,144 @@ +export function intersection<T>(a: T[], b: T[]): T[] { + return a.filter((x) => b.includes(x)) +} + +/** + * Computes a stable hash string for any value using deep inspection. + * This function recursively builds a string for primitives, arrays, and objects. + * It uses a cache (WeakMap) to avoid rehashing the same object twice, which is + * particularly beneficial if an object appears in multiple places. + */ +function deepHash(value: any, cache = new WeakMap<object, string>()): string { + // Handle primitives and null/undefined. + if (value === null) return 'null' + if (value === undefined) return 'undefined' + const type = typeof value + if (type === 'number' || type === 'boolean' || type === 'string') { + return `${type}:${value.toString()}` + } + if (type === 'function') { + // Note: using toString for functions. + return `function:${value.toString()}` + } + + // For objects and arrays, use caching to avoid repeated work. + if (type === 'object') { + // If we’ve seen this object before, return the cached hash. + if (cache.has(value)) { + return cache.get(value)! + } + let hash: string + if (Array.isArray(value)) { + // Compute hash for each element in order. + hash = `array:[${value.map((v) => deepHash(v, cache)).join(',')}]` + } else { + // For objects, sort keys to ensure the representation is stable. + const keys = Object.keys(value).sort() + const props = keys + .map((k) => `${k}:${deepHash(value[k], cache)}`) + .join(',') + hash = `object:{${props}}` + } + cache.set(value, hash) + return hash + } + + // Fallback if no case matched. + return `${type}:${value.toString()}` +} + +/** + * Performs deep equality check for any two values. + * This recursively checks primitives, arrays, and plain objects. + */ +function deepEqual(a: any, b: any): boolean { + // Check strict equality first. + if (a === b) return true + // If types differ, they’re not equal. + if (typeof a !== typeof b) return false + if (a === null || b === null || a === undefined || b === undefined) + return false + + // Check arrays. + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + + // Check objects. + if (typeof a === 'object') { + if (typeof b !== 'object') return false + const aKeys = Object.keys(a).sort() + const bKeys = Object.keys(b).sort() + if (aKeys.length !== bKeys.length) return false + for (let i = 0; i < aKeys.length; i++) { + if (aKeys[i] !== bKeys[i]) return false + if (!deepEqual(a[aKeys[i]], b[bKeys[i]])) return false + } + return true + } + + // For any other types (should be primitives by now), use strict equality. + return false +} + +/** + * Returns a new array containing only the unique values from the input array. + * Uniqueness is determined by deep equality. + * + * @param arr - The array of values to be filtered. + * @returns A new array with duplicates removed. + */ +export function uniq<T>(arr: T[]): T[] { + // Use a Map where key is the deep hash and value is an array of items sharing the same hash. + const seen = new Map<string, T[]>() + const result: T[] = [] + + for (const item of arr) { + const hash = deepHash(item) + if (seen.has(hash)) { + // There is a potential duplicate; check the stored items with the same hash. + const itemsWithHash = seen.get(hash)! + let duplicateFound = false + for (const existing of itemsWithHash) { + if (deepEqual(existing, item)) { + duplicateFound = true + break + } + } + if (!duplicateFound) { + itemsWithHash.push(item) + result.push(item) + } + } else { + // First time this hash appears. + seen.set(hash, [item]) + result.push(item) + } + } + + return result +} + +export function take<T>(a: T[], n: number): T[] { + return a.slice(0, n) +} + +export function flatten<T>(a: T[][]): T[] { + return a.flat() +} + +export function addUniq<T>(arr: T[], values: T[]): T[] { + return uniq([...arr, ...values]) +} + +export function removeUniq<T>(arr: T[], values: T[]): T[] { + return arr.filter((v) => !values.includes(v)) +} + +export function isAnyOf<T>(value: T, values: T[]): boolean { + return values.includes(value) +} diff --git a/packages/components/src/ui/data-table-filter/lib/debounce.ts b/packages/components/src/ui/data-table-filter/lib/debounce.ts new file mode 100644 index 00000000..33ab9ccb --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/debounce.ts @@ -0,0 +1,138 @@ +type ControlFunctions = { + cancel: () => void + flush: () => void + isPending: () => boolean +} + +type DebounceOptions = { + leading?: boolean + trailing?: boolean + maxWait?: number +} + +export function debounce<T extends (...args: any[]) => any>( + func: T, + wait: number, + options: DebounceOptions = {}, +): ((...args: Parameters<T>) => ReturnType<T> | undefined) & ControlFunctions { + const { leading = false, trailing = true, maxWait } = options + let timeout: NodeJS.Timeout | null = null + let lastArgs: Parameters<T> | null = null + let lastThis: any + let result: ReturnType<T> | undefined + let lastCallTime: number | null = null + let lastInvokeTime = 0 + + const maxWaitTime = maxWait !== undefined ? Math.max(wait, maxWait) : null + + function invokeFunc(time: number): ReturnType<T> | undefined { + if (lastArgs === null) return undefined + const args = lastArgs + const thisArg = lastThis + lastArgs = null + lastThis = null + lastInvokeTime = time + result = func.apply(thisArg, args) + return result + } + + function shouldInvoke(time: number): boolean { + if (lastCallTime === null) return false + const timeSinceLastCall = time - lastCallTime + const timeSinceLastInvoke = time - lastInvokeTime + return ( + lastCallTime === null || + timeSinceLastCall >= wait || + timeSinceLastCall < 0 || + (maxWaitTime !== null && timeSinceLastInvoke >= maxWaitTime) + ) + } + + function startTimer( + pendingFunc: () => void, + waitTime: number, + ): NodeJS.Timeout { + return setTimeout(pendingFunc, waitTime) + } + + function remainingWait(time: number): number { + if (lastCallTime === null) return wait + const timeSinceLastCall = time - lastCallTime + const timeSinceLastInvoke = time - lastInvokeTime + const timeWaiting = wait - timeSinceLastCall + return maxWaitTime !== null + ? Math.min(timeWaiting, maxWaitTime - timeSinceLastInvoke) + : timeWaiting + } + + function timerExpired() { + const time = Date.now() + if (shouldInvoke(time)) { + return trailingEdge(time) + } + timeout = startTimer(timerExpired, remainingWait(time)) + } + + function leadingEdge(time: number): ReturnType<T> | undefined { + lastInvokeTime = time + timeout = startTimer(timerExpired, wait) + return leading ? invokeFunc(time) : undefined + } + + function trailingEdge(time: number): ReturnType<T> | undefined { + timeout = null + if (trailing && lastArgs) { + return invokeFunc(time) + } + lastArgs = null + lastThis = null + return result + } + + function debounced( + this: any, + ...args: Parameters<T> + ): ReturnType<T> | undefined { + const time = Date.now() + const isInvoking = shouldInvoke(time) + + lastArgs = args + lastThis = this + lastCallTime = time + + if (isInvoking) { + if (timeout === null) { + return leadingEdge(lastCallTime) + } + if (maxWaitTime !== null) { + timeout = startTimer(timerExpired, wait) + return invokeFunc(lastCallTime) + } + } + if (timeout === null) { + timeout = startTimer(timerExpired, wait) + } + return result + } + + debounced.cancel = () => { + if (timeout !== null) { + clearTimeout(timeout) + } + lastInvokeTime = 0 + lastArgs = null + lastThis = null + lastCallTime = null + timeout = null + } + + debounced.flush = () => { + return timeout === null ? result : trailingEdge(Date.now()) + } + + debounced.isPending = () => { + return timeout !== null + } + + return debounced +} diff --git a/packages/components/src/ui/data-table-filter/lib/filter-fns.ts b/packages/components/src/ui/data-table-filter/lib/filter-fns.ts new file mode 100644 index 00000000..2b030522 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/filter-fns.ts @@ -0,0 +1,175 @@ +import { + endOfDay, + isAfter, + isBefore, + isSameDay, + isWithinInterval, + startOfDay, +} from 'date-fns' +import { dateFilterOperators } from '../core/operators' +import type { FilterModel } from '../core/types' +import { intersection } from './array' + +export function optionFilterFn<TData>( + inputData: string, + filterValue: FilterModel<'option'>, +) { + if (!inputData) return false + if (filterValue.values.length === 0) return true + + const value = inputData.toString().toLowerCase() + + const found = !!filterValue.values.find((v) => v.toLowerCase() === value) + + switch (filterValue.operator) { + case 'is': + case 'is any of': + return found + case 'is not': + case 'is none of': + return !found + } +} + +export function multiOptionFilterFn( + inputData: string[], + filterValue: FilterModel<'multiOption'>, +) { + if (!inputData) return false + + if ( + filterValue.values.length === 0 || + !filterValue.values[0] || + filterValue.values[0].length === 0 + ) + return true + + const values = inputData + const filterValues = filterValue.values + + switch (filterValue.operator) { + case 'include': + case 'include any of': + return intersection(values, filterValues).length > 0 + case 'exclude': + return intersection(values, filterValues).length === 0 + case 'exclude if any of': + return !(intersection(values, filterValues).length > 0) + case 'include all of': + return intersection(values, filterValues).length === filterValues.length + case 'exclude if all': + return !( + intersection(values, filterValues).length === filterValues.length + ) + } +} + +export function dateFilterFn<TData>( + inputData: Date, + filterValue: FilterModel<'date'>, +) { + if (!filterValue || filterValue.values.length === 0) return true + + if ( + dateFilterOperators[filterValue.operator].target === 'single' && + filterValue.values.length > 1 + ) + throw new Error('Singular operators require at most one filter value') + + if ( + filterValue.operator in ['is between', 'is not between'] && + filterValue.values.length !== 2 + ) + throw new Error('Plural operators require two filter values') + + const filterVals = filterValue.values + const d1 = filterVals[0] + const d2 = filterVals[1] + + const value = inputData + + switch (filterValue.operator) { + case 'is': + return isSameDay(value, d1) + case 'is not': + return !isSameDay(value, d1) + case 'is before': + return isBefore(value, startOfDay(d1)) + case 'is on or after': + return isSameDay(value, d1) || isAfter(value, startOfDay(d1)) + case 'is after': + return isAfter(value, startOfDay(d1)) + case 'is on or before': + return isSameDay(value, d1) || isBefore(value, startOfDay(d1)) + case 'is between': + return isWithinInterval(value, { + start: startOfDay(d1), + end: endOfDay(d2), + }) + case 'is not between': + return !isWithinInterval(value, { + start: startOfDay(filterValue.values[0]), + end: endOfDay(filterValue.values[1]), + }) + } +} + +export function textFilterFn<TData>( + inputData: string, + filterValue: FilterModel<'text'>, +) { + if (!filterValue || filterValue.values.length === 0) return true + + const value = inputData.toLowerCase().trim() + const filterStr = filterValue.values[0].toLowerCase().trim() + + if (filterStr === '') return true + + const found = value.includes(filterStr) + + switch (filterValue.operator) { + case 'contains': + return found + case 'does not contain': + return !found + } +} + +export function numberFilterFn<TData>( + inputData: number, + filterValue: FilterModel<'number'>, +) { + if (!filterValue || !filterValue.values || filterValue.values.length === 0) { + return true + } + + const value = inputData + const filterVal = filterValue.values[0] + + switch (filterValue.operator) { + case 'is': + return value === filterVal + case 'is not': + return value !== filterVal + case 'is greater than': + return value > filterVal + case 'is greater than or equal to': + return value >= filterVal + case 'is less than': + return value < filterVal + case 'is less than or equal to': + return value <= filterVal + case 'is between': { + const lowerBound = filterValue.values[0] + const upperBound = filterValue.values[1] + return value >= lowerBound && value <= upperBound + } + case 'is not between': { + const lowerBound = filterValue.values[0] + const upperBound = filterValue.values[1] + return value < lowerBound || value > upperBound + } + default: + return true + } +} diff --git a/packages/components/src/ui/data-table-filter/lib/helpers.ts b/packages/components/src/ui/data-table-filter/lib/helpers.ts new file mode 100644 index 00000000..0d3c8384 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/helpers.ts @@ -0,0 +1,99 @@ +import { isBefore } from 'date-fns' +import type { Column, ColumnOption } from '../core/types' + +export function getColumn<TData>(columns: Column<TData>[], id: string) { + const column = columns.find((c) => c.id === id) + + if (!column) { + throw new Error(`Column with id ${id} not found`) + } + + return column +} + +export function createNumberFilterValue( + values: number[] | undefined, +): number[] { + if (!values || values.length === 0) return [] + if (values.length === 1) return [values[0]] + if (values.length === 2) return createNumberRange(values) + return [values[0], values[1]] +} + +export function createDateFilterValue( + values: [Date, Date] | [Date] | [] | undefined, +) { + if (!values || values.length === 0) return [] + if (values.length === 1) return [values[0]] + if (values.length === 2) return createDateRange(values) + throw new Error('Cannot create date filter value from more than 2 values') +} + +export function createDateRange(values: [Date, Date]) { + const [a, b] = values + const [min, max] = isBefore(a, b) ? [a, b] : [b, a] + + return [min, max] +} + +export function createNumberRange(values: number[] | undefined) { + let a = 0 + let b = 0 + + if (!values || values.length === 0) return [a, b] + if (values.length === 1) { + a = values[0] + } else { + a = values[0] + b = values[1] + } + + const [min, max] = a < b ? [a, b] : [b, a] + + return [min, max] +} + +export function isColumnOption(value: unknown): value is ColumnOption { + return ( + typeof value === 'object' && + value !== null && + 'value' in value && + 'label' in value + ) +} + +export function isColumnOptionArray(value: unknown): value is ColumnOption[] { + return Array.isArray(value) && value.every(isColumnOption) +} + +export function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === 'string') +} + +export function isColumnOptionMap( + value: unknown, +): value is Map<string, number> { + if (!(value instanceof Map)) { + return false + } + for (const key of value.keys()) { + if (typeof key !== 'string') { + return false + } + } + for (const val of value.values()) { + if (typeof val !== 'number') { + return false + } + } + return true +} + +export function isMinMaxTuple(value: unknown): value is [number, number] { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number' + ) +} diff --git a/packages/components/src/ui/data-table-filter/lib/i18n.ts b/packages/components/src/ui/data-table-filter/lib/i18n.ts new file mode 100644 index 00000000..75c9988e --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/i18n.ts @@ -0,0 +1,13 @@ +import en from '../locales/en.json' + +export type Locale = 'en' + +type Translations = Record<string, string> + +const translations: Record<Locale, Translations> = { + en, +} + +export function t(key: string, locale: Locale): string { + return translations[locale][key] ?? key +} diff --git a/packages/components/src/ui/data-table-filter/lib/memo.ts b/packages/components/src/ui/data-table-filter/lib/memo.ts new file mode 100644 index 00000000..528c371f --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/memo.ts @@ -0,0 +1,35 @@ +export function memo<TDeps extends readonly any[], TResult>( + getDeps: () => TDeps, + compute: (deps: TDeps) => TResult, + options: { key: string }, +): () => TResult { + let prevDeps: TDeps | undefined + let cachedResult: TResult | undefined + + return () => { + // console.log(`[memo] Calling memoized function: ${options.key}`) + + const deps = getDeps() + + // If no previous deps or deps have changed, recompute + if (!prevDeps || !shallowEqual(prevDeps, deps)) { + // console.log(`[memo] Cache MISS - ${options.key}`) + cachedResult = compute(deps) + prevDeps = deps + } else { + // console.log(`[memo] Cache HIT - ${options.key}`) + } + + return cachedResult! + } +} + +function shallowEqual<T>(arr1: readonly T[], arr2: readonly T[]): boolean { + if (arr1 === arr2) return true + if (arr1.length !== arr2.length) return false + + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) return false + } + return true +} diff --git a/packages/components/src/ui/data-table-filter/locales/en.json b/packages/components/src/ui/data-table-filter/locales/en.json new file mode 100644 index 00000000..695c2910 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/locales/en.json @@ -0,0 +1,42 @@ +{ + "clear": "Clear", + "search": "Search...", + "noresults": "No results.", + "operators": "Operators", + "filter": "Filter", + "and": "and", + "single": "Single", + "range": "Range", + "value": "Value", + "min": "Min", + "max": "Max", + "filters.option.is": "is", + "filters.option.isNot": "is not", + "filters.option.isAnyOf": "is any of", + "filters.option.isNoneOf": "is none of", + "filters.multiOption.include": "includes", + "filters.multiOption.exclude": "excludes", + "filters.multiOption.includeAnyOf": "includes any of", + "filters.multiOption.excludeAllOf": "excludes all of", + "filters.multiOption.includeAllOf": "includes all of", + "filters.multiOption.excludeIfAnyOf": "excludes if any of", + "filters.multiOption.excludeIfAll": "excludes if all of", + "filters.date.is": "is", + "filters.date.isNot": "is not", + "filters.date.isBefore": "is before", + "filters.date.isOnOrAfter": "is on or after", + "filters.date.isAfter": "is after", + "filters.date.isOnOrBefore": "is on or before", + "filters.date.isBetween": "is between", + "filters.date.isNotBetween": "is not between", + "filters.text.contains": "contains", + "filters.text.doesNotContain": "does not contain", + "filters.number.is": "is", + "filters.number.isNot": "is not", + "filters.number.greaterThan": "greater than", + "filters.number.greaterThanOrEqual": "greater than or equal", + "filters.number.lessThan": "less than", + "filters.number.lessThanOrEqual": "less than or equal", + "filters.number.isBetween": "is between", + "filters.number.isNotBetween": "is not between" +} diff --git a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx index 7428b6eb..1c1cab9f 100644 --- a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx +++ b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx @@ -4,15 +4,7 @@ import type * as React from 'react'; import { useEffect, useState } from 'react'; import { Badge } from '../badge'; import { Button } from '../button'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from '../command'; +import { CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '../command'; import { Popover, PopoverContent, PopoverTrigger } from '../popover'; import { Separator } from '../separator'; import { cn } from '../utils'; @@ -53,13 +45,13 @@ export function DataTableFacetedFilter<TData>({ next.add(value); } const filterValues = Array.from(next); - + if (onValuesChange) { onValuesChange(filterValues); } else if (column) { column.setFilterValue(filterValues.length ? filterValues : undefined); } - + return next; }); }; @@ -105,46 +97,44 @@ export function DataTableFacetedFilter<TData>({ </Button> </PopoverTrigger> <PopoverContent className="w-[200px] p-0" align="start"> - <Command> - <CommandInput placeholder={title} /> - <CommandList> - <CommandEmpty>No results found.</CommandEmpty> - <CommandGroup> - {options.map((option) => { - const isSelected = selected.has(option.value); - return ( - <CommandItem key={option.value} onSelect={() => handleValueChange(option.value)}> - <div - className={cn( - 'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary', - isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible', - )} - > - <CheckIcon className={cn('h-4 w-4')} /> - </div> - {option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />} - <span>{option.label}</span> - {facets?.get(option.value) && ( - <span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs"> - {facets.get(option.value)} - </span> + <CommandInput placeholder={title} /> + <CommandList> + <CommandEmpty>No results found.</CommandEmpty> + <CommandGroup> + {options.map((option) => { + const isSelected = selected.has(option.value); + return ( + <CommandItem key={option.value} onSelect={() => handleValueChange(option.value)}> + <div + className={cn( + 'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary', + isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible', )} - </CommandItem> - ); - })} - </CommandGroup> - {selected.size > 0 && ( - <> - <CommandSeparator /> - <CommandGroup> - <CommandItem onSelect={handleClear} className="justify-center text-center"> - Clear filters - </CommandItem> - </CommandGroup> - </> - )} - </CommandList> - </Command> + > + <CheckIcon className={cn('h-4 w-4')} /> + </div> + {option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />} + <span>{option.label}</span> + {facets?.get(option.value) && ( + <span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs"> + {facets.get(option.value)} + </span> + )} + </CommandItem> + ); + })} + </CommandGroup> + {selected.size > 0 && ( + <> + <CommandSeparator /> + <CommandGroup> + <CommandItem onSelect={handleClear} className="justify-center text-center"> + Clear filters + </CommandItem> + </CommandGroup> + </> + )} + </CommandList> </PopoverContent> </Popover> ); diff --git a/packages/components/src/ui/data-table/data-table.tsx b/packages/components/src/ui/data-table/data-table.tsx index d474d986..3d688c94 100644 --- a/packages/components/src/ui/data-table/data-table.tsx +++ b/packages/components/src/ui/data-table/data-table.tsx @@ -1,4 +1,5 @@ import { type Table as TableType, flexRender } from '@tanstack/react-table'; +import { cn } from '../../ui/utils'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table'; import { DataTablePagination } from './data-table-pagination'; @@ -8,6 +9,7 @@ interface DataTableProps<TData> { pagination?: boolean; onPaginationChange?: (pageIndex: number, pageSize: number) => void; pageCount?: number; + className?: string; } export function DataTable<TData>({ @@ -16,9 +18,10 @@ export function DataTable<TData>({ pagination, onPaginationChange, pageCount = 1, + className, }: DataTableProps<TData>) { return ( - <div className="space-y-4"> + <div className={cn('space-y-4', className)}> <div className="rounded-md border"> <Table> <TableHeader> diff --git a/packages/components/src/ui/dialog.tsx b/packages/components/src/ui/dialog.tsx new file mode 100644 index 00000000..1cc225c0 --- /dev/null +++ b/packages/components/src/ui/dialog.tsx @@ -0,0 +1,111 @@ +'use client'; + +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { XIcon } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '../ui/utils'; + +function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { + return <DialogPrimitive.Root data-slot="dialog" {...props} />; +} + +function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; +} + +function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; +} + +function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { + return ( + <DialogPrimitive.Overlay + data-slot="dialog-overlay" + className={cn( + 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', + className, + )} + {...props} + /> + ); +} + +function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) { + return ( + <DialogPortal data-slot="dialog-portal"> + <DialogOverlay /> + <DialogPrimitive.Content + data-slot="dialog-content" + className={cn( + 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', + className, + )} + {...props} + > + {children} + <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> + <XIcon /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="dialog-header" + className={cn('flex flex-col gap-2 text-center sm:text-left', className)} + {...props} + /> + ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot="dialog-footer" + className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} + {...props} + /> + ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { + return ( + <DialogPrimitive.Title + data-slot="dialog-title" + className={cn('text-lg leading-none font-semibold', className)} + {...props} + /> + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) { + return ( + <DialogPrimitive.Description + data-slot="dialog-description" + className={cn('text-muted-foreground text-sm', className)} + {...props} + /> + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/packages/components/src/ui/popover.tsx b/packages/components/src/ui/popover.tsx index 3f0c1087..508673bb 100644 --- a/packages/components/src/ui/popover.tsx +++ b/packages/components/src/ui/popover.tsx @@ -8,6 +8,8 @@ const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + function PopoverContent({ className, align = 'center', @@ -30,4 +32,4 @@ function PopoverContent({ ); } -export { Popover, PopoverTrigger, PopoverContent }; +export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }; diff --git a/packages/components/src/ui/select.tsx b/packages/components/src/ui/select.tsx index 8abf40ee..10dcb895 100644 --- a/packages/components/src/ui/select.tsx +++ b/packages/components/src/ui/select.tsx @@ -1,21 +1,10 @@ -import * as React from 'react'; import { Check, ChevronDown } from 'lucide-react'; +import * as React from 'react'; -import { cn } from './utils'; import { Button } from './button'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from './command'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from './popover'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { cn } from './utils'; export interface SelectOption { label: string; @@ -40,8 +29,8 @@ export function Select({ className, }: SelectProps) { const [open, setOpen] = React.useState(false); - - const selectedOption = options.find(option => option.value === value); + + const selectedOption = options.find((option) => option.value === value); return ( <Popover open={open} onOpenChange={setOpen}> @@ -50,7 +39,7 @@ export function Select({ variant="outline" role="combobox" aria-expanded={open} - className={cn("w-[200px] justify-between", className)} + className={cn('w-[200px] justify-between', className)} disabled={disabled} > {selectedOption ? selectedOption.label : placeholder} @@ -72,12 +61,7 @@ export function Select({ }} className="flex items-center" > - <Check - className={cn( - "mr-2 h-4 w-4", - value === option.value ? "opacity-100" : "opacity-0" - )} - /> + <Check className={cn('mr-2 h-4 w-4', value === option.value ? 'opacity-100' : 'opacity-0')} /> {option.label} </CommandItem> ))} @@ -87,4 +71,4 @@ export function Select({ </PopoverContent> </Popover> ); -} \ No newline at end of file +} diff --git a/packages/components/src/ui/slider.tsx b/packages/components/src/ui/slider.tsx new file mode 100644 index 00000000..611693e0 --- /dev/null +++ b/packages/components/src/ui/slider.tsx @@ -0,0 +1,53 @@ +import * as SliderPrimitive from '@radix-ui/react-slider'; +import * as React from 'react'; +import { cn } from './utils'; + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps<typeof SliderPrimitive.Root>) { + const _values = React.useMemo( + () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]), + [value, defaultValue, min, max], + ); + + return ( + <SliderPrimitive.Root + data-slot="slider" + defaultValue={defaultValue} + value={value} + min={min} + max={max} + className={cn( + 'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col', + className, + )} + {...props} + > + <SliderPrimitive.Track + data-slot="slider-track" + className={cn( + 'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5', + )} + > + <SliderPrimitive.Range + data-slot="slider-range" + className={cn('bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full')} + /> + </SliderPrimitive.Track> + {Array.from({ length: _values.length }, (_, index) => ( + <SliderPrimitive.Thumb + data-slot="slider-thumb" + key={index} + className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" + /> + ))} + </SliderPrimitive.Root> + ); +} + +export { Slider }; diff --git a/packages/components/src/ui/tabs.tsx b/packages/components/src/ui/tabs.tsx new file mode 100644 index 00000000..ed53a6e0 --- /dev/null +++ b/packages/components/src/ui/tabs.tsx @@ -0,0 +1,39 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import type * as React from 'react'; +import { cn } from './utils'; + +function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { + return <TabsPrimitive.Root data-slot="tabs" className={cn('flex flex-col gap-2', className)} {...props} />; +} + +function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) { + return ( + <TabsPrimitive.List + data-slot="tabs-list" + className={cn( + 'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]', + className, + )} + {...props} + /> + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { + return ( + <TabsPrimitive.Trigger + data-slot="tabs-trigger" + className={cn( + "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className, + )} + {...props} + /> + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) { + return <TabsPrimitive.Content data-slot="tabs-content" className={cn('flex-1 outline-none', className)} {...props} />; +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/packages/components/src/ui/utils/debounce.ts b/packages/components/src/ui/utils/debounce.ts index 815e3dec..e01ba21b 100644 --- a/packages/components/src/ui/utils/debounce.ts +++ b/packages/components/src/ui/utils/debounce.ts @@ -1,27 +1,149 @@ /** - * Creates a debounced function that delays invoking the provided function - * until after the specified wait time has elapsed since the last time it was invoked. - * + * Creates a debounced function that delays invoking `func` until after `wait` milliseconds + * have elapsed since the last time the debounced function was invoked. + * * @param func The function to debounce * @param wait The number of milliseconds to delay - * @returns A debounced version of the provided function + * @param options The options object + * @returns The debounced function */ + +// biome-ignore lint/suspicious/noExplicitAny: debounce is a utility function that is used to debounce a function export function debounce<T extends (...args: any[]) => any>( func: T, - wait: number -): (...args: Parameters<T>) => void { - let timeout: ReturnType<typeof setTimeout> | null = null; - - return function(...args: Parameters<T>): void { - const later = () => { - timeout = null; - func(...args); - }; - - if (timeout !== null) { - clearTimeout(timeout); + wait = 300, + options: { + leading?: boolean; + trailing?: boolean; + maxWait?: number; + } = {}, +): { + (...args: Parameters<T>): ReturnType<T> | undefined; + cancel: () => void; + flush: () => ReturnType<T> | undefined; +} { + let lastArgs: Parameters<T> | undefined; + // biome-ignore lint/suspicious/noExplicitAny: lastThis is used to store the context of the function + let lastThis: any; + const maxWait: number | undefined = options.maxWait; + let result: ReturnType<T> | undefined; + let timerId: ReturnType<typeof setTimeout> | undefined; + let lastCallTime: number | undefined; + let lastInvokeTime = 0; + const leading = !!options.leading; + const trailing = 'trailing' in options ? !!options.trailing : true; + + function invokeFunc(time: number) { + const args = lastArgs; + const thisArg = lastThis; + + lastArgs = lastThis = undefined; + lastInvokeTime = time; + result = func.apply(thisArg, args as Parameters<T>); + return result; + } + + function startTimer(pendingFunc: () => void, wait: number) { + return setTimeout(pendingFunc, wait); + } + + function cancelTimer(id: ReturnType<typeof setTimeout>) { + clearTimeout(id); + } + + function leadingEdge(time: number) { + // Reset any `maxWait` timer. + lastInvokeTime = time; + // Start the timer for the trailing edge. + timerId = startTimer(timerExpired, wait); + // Invoke the leading edge. + return leading ? invokeFunc(time) : result; + } + + function remainingWait(time: number) { + const timeSinceLastCall = time - (lastCallTime as number); + const timeSinceLastInvoke = time - lastInvokeTime; + const timeWaiting = wait - timeSinceLastCall; + + return maxWait !== undefined ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting; + } + + function shouldInvoke(time: number) { + const timeSinceLastCall = time - (lastCallTime as number); + const timeSinceLastInvoke = time - lastInvokeTime; + + // Either this is the first call, activity has stopped and we're at the + // trailing edge, the system time has gone backwards and we're treating + // it as the trailing edge, or we've hit the `maxWait` limit. + return ( + lastCallTime === undefined || + timeSinceLastCall >= wait || + timeSinceLastCall < 0 || + (maxWait !== undefined && timeSinceLastInvoke >= maxWait) + ); + } + + function timerExpired() { + const time = Date.now(); + if (shouldInvoke(time)) { + return trailingEdge(time); } - - timeout = setTimeout(later, wait); - }; -} \ No newline at end of file + // Restart the timer. + timerId = startTimer(timerExpired, remainingWait(time)); + return undefined; + } + + function trailingEdge(time: number) { + timerId = undefined; + + // Only invoke if we have `lastArgs` which means `func` has been + // debounced at least once. + if (trailing && lastArgs) { + return invokeFunc(time); + } + lastArgs = lastThis = undefined; + return result; + } + + function cancel() { + if (timerId !== undefined) { + cancelTimer(timerId); + } + lastInvokeTime = 0; + lastArgs = lastCallTime = lastThis = timerId = undefined; + } + + function flush() { + return timerId === undefined ? result : trailingEdge(Date.now()); + } + + // biome-ignore lint/suspicious/noExplicitAny: debounced is a utility function that is used to debounce a function + function debounced(this: any, ...args: Parameters<T>) { + const time = Date.now(); + const isInvoking = shouldInvoke(time); + + lastArgs = args; + lastThis = this; + lastCallTime = time; + + if (isInvoking) { + if (timerId === undefined) { + return leadingEdge(lastCallTime); + } + if (maxWait !== undefined) { + // Handle invocations in a tight loop. + timerId = startTimer(timerExpired, wait); + return invokeFunc(lastCallTime); + } + } + if (timerId === undefined) { + timerId = startTimer(timerExpired, wait); + } + return result; + } + + debounced.cancel = cancel; + debounced.flush = flush; + + return debounced; +} diff --git a/packages/components/src/ui/utils/filters.ts b/packages/components/src/ui/utils/filters.ts new file mode 100644 index 00000000..a02619f8 --- /dev/null +++ b/packages/components/src/ui/utils/filters.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +export const filterOperatorSchema = z.enum([ + 'is any of', + 'is none of', + 'contains', + 'does not contain', + // Add other operators as needed +]); + +export const filterTypeSchema = z.enum([ + 'option', + 'text', + 'number', + 'date', + // Add other types as needed +]); + +export const filterSchema = z.object({ + columnId: z.string(), + type: filterTypeSchema, + operator: filterOperatorSchema, + values: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), +}); + +export const filtersArraySchema = z.array(filterSchema); + +export type FilterOperator = z.infer<typeof filterOperatorSchema>; +export type FilterType = z.infer<typeof filterTypeSchema>; +export type Filter = z.infer<typeof filterSchema>; +export type FiltersState = z.infer<typeof filtersArraySchema>; diff --git a/packages/components/src/ui/utils/use-data-table-filters.tsx b/packages/components/src/ui/utils/use-data-table-filters.tsx new file mode 100644 index 00000000..affd10e8 --- /dev/null +++ b/packages/components/src/ui/utils/use-data-table-filters.tsx @@ -0,0 +1,318 @@ +'use client'; + +import type React from 'react'; +import { useMemo, useState } from 'react'; +import { createColumns } from '../data-table-filter/core/filters'; +import { DEFAULT_OPERATORS, determineNewOperator } from '../data-table-filter/core/operators'; +import type { + ColumnConfig, + ColumnDataType, + ColumnOption, + DataTableFilterActions, + FilterModel, + FilterStrategy, + FiltersState, + NumberColumnIds, + OptionBasedColumnDataType, + OptionColumnIds, +} from '../data-table-filter/core/types'; +import { addUniq, removeUniq, uniq } from '../data-table-filter/lib/array'; +import { + createDateFilterValue, + createNumberFilterValue, + isColumnOptionArray, + isColumnOptionMap, + isMinMaxTuple, +} from '../data-table-filter/lib/helpers'; + +export interface DataTableFiltersOptions< + TData, + // biome-ignore lint/suspicious/noExplicitAny: can be any + TColumns extends readonly ColumnConfig<TData, any, any, any>[], + TStrategy extends FilterStrategy, +> { + strategy: TStrategy; + data: TData[]; + columnsConfig: TColumns; + defaultFilters?: FiltersState; + filters?: FiltersState; + onFiltersChange?: React.Dispatch<React.SetStateAction<FiltersState>>; + options?: Partial<Record<OptionColumnIds<TColumns>, ColumnOption[] | undefined>>; + faceted?: Partial< + | Record<OptionColumnIds<TColumns>, Map<string, number> | undefined> + | Record<NumberColumnIds<TColumns>, [number, number] | undefined> + >; +} + +export function useDataTableFilters< + TData, + // biome-ignore lint/suspicious/noExplicitAny: can be any + TColumns extends readonly ColumnConfig<TData, any, any, any>[], + TStrategy extends FilterStrategy, +>({ + strategy, + data, + columnsConfig, + defaultFilters, + filters: externalFilters, + onFiltersChange, + options, + faceted, +}: DataTableFiltersOptions<TData, TColumns, TStrategy>) { + const [internalFilters, setInternalFilters] = useState<FiltersState>(defaultFilters ?? []); + + if ((externalFilters && !onFiltersChange) || (!externalFilters && onFiltersChange)) { + throw new Error('If using controlled state, you must specify both filters and onFiltersChange.'); + } + + const filters = externalFilters ?? internalFilters; + const setFilters = onFiltersChange ?? setInternalFilters; + + // Convert ColumnConfig to Column, applying options and faceted options if provided + const columns = useMemo(() => { + const enhancedConfigs = columnsConfig.map((config) => { + let final = config; + + // Set options, if exists + if (options && (config.type === 'option' || config.type === 'multiOption')) { + const optionsInput = options[config.id as OptionColumnIds<TColumns>]; + if (!(optionsInput && isColumnOptionArray(optionsInput))) return config; + + final = { ...final, options: optionsInput }; + } + + // Set faceted options, if exists + if (faceted instanceof Map && (config.type === 'option' || config.type === 'multiOption')) { + const potentialMapForColumn = faceted.get(config.id as OptionColumnIds<TColumns>); + if (potentialMapForColumn && isColumnOptionMap(potentialMapForColumn)) { + final = { ...final, facetedOptions: potentialMapForColumn }; + } else { + // If faceted is a Map but the entry for this column isn't a Map or doesn't exist, return original config. + return config; + } + } else if (config.type === 'option' || config.type === 'multiOption') { + // If faceted is not a Map (or not provided) but it's an option column, return original config. + return config; + } + + // Set faceted min/max values, if exists + if (faceted instanceof Map && config.type === 'number') { + const potentialTupleForColumn = faceted.get(config.id as NumberColumnIds<TColumns>); + if (potentialTupleForColumn && isMinMaxTuple(potentialTupleForColumn)) { + final = { + ...final, + min: potentialTupleForColumn[0], + max: potentialTupleForColumn[1], + }; + } else { + // If faceted is a Map but the entry for this column isn't a tuple or doesn't exist, return original config. + return config; + } + } else if (config.type === 'number') { + // If faceted is not a Map (or not provided) but it's a number column, return original config. + return config; + } + + return final; + }); + + // --- MODIFIED DEBUG LOG HERE --- + console.log('[useDataTableFilters] Inspecting enhancedConfigs before passing to createColumns:'); + enhancedConfigs.forEach((config, index) => { + console.log(` [Enhanced Config Index: ${index}, ID: ${config.id}]`, config); + if (config.facetedOptions instanceof Map) { + console.log(` ↳ Faceted Options (Map entries):`, Array.from(config.facetedOptions.entries())); + } else { + console.log(` ↳ Faceted Options:`, config.facetedOptions); // Log it directly if not a map, to see what it is + } + }); + // --- END DEBUG LOG --- + + return createColumns(data, enhancedConfigs, strategy); + }, [data, columnsConfig, options, faceted, strategy]); + + const actions: DataTableFilterActions = useMemo( + () => ({ + addFilterValue<TData, TType extends OptionBasedColumnDataType>( + column: ColumnConfig<TData, TType>, + values: FilterModel<TType>['values'], + ) { + if (column.type === 'option') { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id); + const isColumnFiltered = filter && filter.values.length > 0; + if (!isColumnFiltered) { + return [ + ...prev, + { + columnId: column.id, + type: column.type, + operator: + values.length > 1 ? DEFAULT_OPERATORS[column.type].multiple : DEFAULT_OPERATORS[column.type].single, + values, + }, + ]; + } + const oldValues = filter.values; + const newValues = addUniq(filter.values, values); + const newOperator = determineNewOperator('option', oldValues, newValues, filter.operator); + return prev.map((f) => + f.columnId === column.id + ? { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues, + } + : f, + ); + }); + return; + } + if (column.type === 'multiOption') { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id); + const isColumnFiltered = filter && filter.values.length > 0; + if (!isColumnFiltered) { + return [ + ...prev, + { + columnId: column.id, + type: column.type, + operator: + values.length > 1 ? DEFAULT_OPERATORS[column.type].multiple : DEFAULT_OPERATORS[column.type].single, + values, + }, + ]; + } + const oldValues = filter.values; + const newValues = addUniq(filter.values, values); + const newOperator = determineNewOperator('multiOption', oldValues, newValues, filter.operator); + if (newValues.length === 0) { + return prev.filter((f) => f.columnId !== column.id); + } + return prev.map((f) => + f.columnId === column.id + ? { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues, + } + : f, + ); + }); + return; + } + throw new Error('[data-table-filter] addFilterValue() is only supported for option columns'); + }, + removeFilterValue<TData, TType extends OptionBasedColumnDataType>( + column: ColumnConfig<TData, TType>, + value: FilterModel<TType>['values'], + ) { + if (column.type === 'option') { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id); + const isColumnFiltered = filter && filter.values.length > 0; + if (!isColumnFiltered) { + return [...prev]; + } + const newValues = removeUniq(filter.values, value); + const oldValues = filter.values; + const newOperator = determineNewOperator('option', oldValues, newValues, filter.operator); + if (newValues.length === 0) { + return prev.filter((f) => f.columnId !== column.id); + } + return prev.map((f) => + f.columnId === column.id + ? { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues, + } + : f, + ); + }); + return; + } + if (column.type === 'multiOption') { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id); + const isColumnFiltered = filter && filter.values.length > 0; + if (!isColumnFiltered) { + return [...prev]; + } + const newValues = removeUniq(filter.values, value); + const oldValues = filter.values; + const newOperator = determineNewOperator('multiOption', oldValues, newValues, filter.operator); + if (newValues.length === 0) { + return prev.filter((f) => f.columnId !== column.id); + } + return prev.map((f) => + f.columnId === column.id + ? { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues, + } + : f, + ); + }); + return; + } + throw new Error('[data-table-filter] removeFilterValue() is only supported for option columns'); + }, + setFilterValue<TData, TType extends ColumnDataType>( + column: ColumnConfig<TData, TType>, + values: FilterModel<TType>['values'], + ) { + setFilters((prev) => { + const filter = prev.find((f) => f.columnId === column.id); + const isColumnFiltered = filter && filter.values.length > 0; + const newValues = + column.type === 'number' + ? createNumberFilterValue(values as number[]) + : column.type === 'date' + ? createDateFilterValue(values as [Date, Date] | [Date] | [] | undefined) + : uniq(values); + if (newValues.length === 0) return prev; + if (!isColumnFiltered) { + return [ + ...prev, + { + columnId: column.id, + type: column.type, + operator: + values.length > 1 ? DEFAULT_OPERATORS[column.type].multiple : DEFAULT_OPERATORS[column.type].single, + values: newValues, + }, + ]; + } + const oldValues = filter.values; + const newOperator = determineNewOperator(column.type, oldValues, newValues, filter.operator); + const newFilter = { + columnId: column.id, + type: column.type, + operator: newOperator, + values: newValues as FilterModel<TType>['values'], + } satisfies FilterModel<TType>; + return prev.map((f) => (f.columnId === column.id ? newFilter : f)); + }); + }, + setFilterOperator<TType extends ColumnDataType>(columnId: string, operator: FilterModel<TType>['operator']) { + setFilters((prev) => prev.map((f) => (f.columnId === columnId ? { ...f, operator } : f))); + }, + removeFilter(columnId: string) { + setFilters((prev) => prev.filter((f) => f.columnId !== columnId)); + }, + removeAllFilters() { + setFilters([]); + }, + }), + [setFilters], + ); + + return { columns, filters, actions, strategy }; // columns is Column<TData>[] +} diff --git a/packages/components/src/ui/utils/use-debounce-callback.tsx b/packages/components/src/ui/utils/use-debounce-callback.tsx new file mode 100644 index 00000000..0365cc5e --- /dev/null +++ b/packages/components/src/ui/utils/use-debounce-callback.tsx @@ -0,0 +1,63 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { debounce } from './debounce'; +import { useUnmount } from './use-unmount'; // This relative path should be correct now + +type DebounceOptions = { + leading?: boolean; + trailing?: boolean; + maxWait?: number; +}; + +type ControlFunctions = { + cancel: () => void; + flush: () => void; + isPending: () => boolean; +}; + +export type DebouncedState<T extends (...args: any) => ReturnType<T>> = (( + ...args: Parameters<T> +) => ReturnType<T> | undefined) & + ControlFunctions; + +export function useDebounceCallback<T extends (...args: any) => ReturnType<T>>( + func: T, + delay = 500, + options?: DebounceOptions, +): DebouncedState<T> { + const debouncedFunc = useRef<ReturnType<typeof debounce>>(null); + + useUnmount(() => { + if (debouncedFunc.current) { + debouncedFunc.current.cancel(); + } + }); + + const debounced = useMemo(() => { + const debouncedFuncInstance = debounce(func, delay, options); + + const wrappedFunc: DebouncedState<T> = (...args: Parameters<T>) => { + return debouncedFuncInstance(...args); + }; + + wrappedFunc.cancel = () => { + debouncedFuncInstance.cancel(); + }; + + wrappedFunc.isPending = () => { + return !!debouncedFunc.current; + }; + + wrappedFunc.flush = () => { + return debouncedFuncInstance.flush(); + }; + + return wrappedFunc; + }, [func, delay, options]); + + // Update the debounced function ref whenever func, wait, or options change + useEffect(() => { + debouncedFunc.current = debounce(func, delay, options); + }, [func, delay, options]); + + return debounced; +} diff --git a/packages/components/src/ui/utils/use-filter-sync.ts b/packages/components/src/ui/utils/use-filter-sync.ts new file mode 100644 index 00000000..8dd0a7c6 --- /dev/null +++ b/packages/components/src/ui/utils/use-filter-sync.ts @@ -0,0 +1,27 @@ +import { parseAsJson, useQueryState } from 'nuqs'; +import { useEffect } from 'react'; +import { type FiltersState, filtersArraySchema } from './filters'; + +/** + * Hook to synchronize the data table filter state with URL query parameters. + * Uses nuqs for encoding/decoding and zod schema for validation. + * + * @returns A tuple containing the current filter state and a function to update it. + */ +export function useFilterSync() { + const [filters, setFilters] = useQueryState<FiltersState>( + 'filters', // The query parameter key + parseAsJson(filtersArraySchema.parse) // Now filtersArraySchema should be defined + .withDefault([]) + .withOptions({ history: 'push', shallow: false, debounce: 300 }) // Add debouncing and history options + ); + + // This effect ensures that when the component mounts, it immediately + // applies any filters from the URL to the data fetching logic + useEffect(() => { + // The filters are already loaded from the URL via useQueryState + // This is just a placeholder for any additional initialization if needed + }, []); + + return [filters, setFilters] as const; +} diff --git a/packages/components/src/ui/utils/use-filtered-data.ts b/packages/components/src/ui/utils/use-filtered-data.ts new file mode 100644 index 00000000..cde51e06 --- /dev/null +++ b/packages/components/src/ui/utils/use-filtered-data.ts @@ -0,0 +1,165 @@ +import { useMemo } from 'react'; +import type { Column, ColumnConfig, DataTableFilterActions, FilterStrategy, FiltersState } from '../data-table-filter/core/types'; +import { createColumns } from '../data-table-filter/core/filters'; +import { useFilterSync } from './use-filter-sync'; +import { useDataQuery } from './use-issues-query'; + +interface UseFilteredDataOptions<TData> { + endpoint: string; + columnsConfig: ReadonlyArray<ColumnConfig<TData, any, any, any>>; + strategy?: FilterStrategy; + initialData?: TData[]; + queryOptions?: { + enabled?: boolean; + refetchInterval?: number | false; + onSuccess?: (data: { data: TData[]; facetedCounts: Record<string, Record<string, number>> }) => void; + onError?: (error: Error) => void; + }; +} + +/** + * A hook that combines filter state management with data fetching. + * It handles: + * 1. Syncing filters with URL query parameters + * 2. Fetching data based on current filters + * 3. Creating columns with faceted counts from the API + * 4. Providing filter actions + * + * @returns Everything needed to implement a filtered data table + */ +export function useFilteredData<TData>({ + endpoint, + columnsConfig, + strategy = 'client', + initialData = [], + queryOptions, +}: UseFilteredDataOptions<TData>) { + // Sync filters with URL query parameters + const [filters, setFilters] = useFilterSync(); + + // Fetch data with current filters + const { data, isLoading, isError, error, refetch } = useDataQuery<TData>( + endpoint, + filters, + queryOptions + ); + + // Create columns with faceted counts from the API + const columns = useMemo(() => { + if (!data) return createColumns(initialData, columnsConfig, strategy); + + // Apply faceted counts from the API to the columns + const enhancedConfig = columnsConfig.map(config => { + if ((config.type === 'option' || config.type === 'multiOption') && data.facetedCounts?.[config.id]) { + return { + ...config, + facetedOptions: new Map( + Object.entries(data.facetedCounts[config.id]).map(([key, count]) => [key, count]) + ) + }; + } + if (config.type === 'number' && data.facetedCounts?.[config.id]) { + // For number columns, we might have min/max values + const values = Object.values(data.facetedCounts[config.id]); + if (values.length === 2) { + return { + ...config, + min: values[0], + max: values[1], + }; + } + } + return config; + }); + + return createColumns(data.data || initialData, enhancedConfig, strategy); + }, [data, columnsConfig, initialData, strategy]); + + // Create filter actions + const actions: DataTableFilterActions = useMemo(() => { + return { + addFilterValue: (column, values) => { + setFilters(prev => { + const filter = prev.find(f => f.columnId === column.id); + if (!filter) { + return [...prev, { + columnId: column.id, + type: column.type, + operator: column.type === 'option' ? 'is any of' : 'contains', + values + }]; + } + return prev.map(f => + f.columnId === column.id + ? { ...f, values: [...new Set([...f.values, ...values])] } + : f + ); + }); + }, + removeFilterValue: (column, values) => { + setFilters(prev => { + const filter = prev.find(f => f.columnId === column.id); + if (!filter) return prev; + + const newValues = filter.values.filter(v => !values.includes(v)); + if (newValues.length === 0) { + return prev.filter(f => f.columnId !== column.id); + } + + return prev.map(f => + f.columnId === column.id + ? { ...f, values: newValues } + : f + ); + }); + }, + setFilterValue: (column, values) => { + setFilters(prev => { + const exists = prev.some(f => f.columnId === column.id); + if (!exists) { + return [...prev, { + columnId: column.id, + type: column.type, + operator: column.type === 'option' ? 'is any of' : 'contains', + values + }]; + } + return prev.map(f => + f.columnId === column.id + ? { ...f, values } + : f + ); + }); + }, + setFilterOperator: (columnId, operator) => { + setFilters(prev => + prev.map(f => + f.columnId === columnId + ? { ...f, operator } + : f + ) + ); + }, + removeFilter: (columnId) => { + setFilters(prev => prev.filter(f => f.columnId !== columnId)); + }, + removeAllFilters: () => { + setFilters([]); + } + }; + }, [setFilters]); + + return { + filters, + setFilters, + columns, + actions, + data: data?.data || initialData, + facetedCounts: data?.facetedCounts, + isLoading, + isError, + error, + refetch, + }; +} + diff --git a/packages/components/src/ui/utils/use-issues-query.ts b/packages/components/src/ui/utils/use-issues-query.ts new file mode 100644 index 00000000..42401488 --- /dev/null +++ b/packages/components/src/ui/utils/use-issues-query.ts @@ -0,0 +1,77 @@ +import { useQuery } from '@tanstack/react-query'; +import type { FiltersState } from './filters'; + +// Define the expected shape of the API response +interface IssuesApiResponse<T = any> { + data: T[]; + facetedCounts: Record<string, Record<string, number>>; +} + +// Generic function to fetch data with filters +async function fetchData<T = any>( + endpoint: string, + filters: FiltersState +): Promise<IssuesApiResponse<T>> { + // Encode filters for URL + const filterParam = filters.length > 0 ? `filters=${encodeURIComponent(JSON.stringify(filters))}` : ''; + const response = await fetch(`${endpoint}?${filterParam}`); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response' })); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + const data: IssuesApiResponse<T> = await response.json(); + return data; +} + +/** + * Custom hook to fetch data using TanStack Query, based on filter state. + * + * @param endpoint The API endpoint to fetch data from + * @param filters The current filter state + * @param options Additional query options + * @returns The TanStack Query result object for the data query + */ +export function useDataQuery<T = any>( + endpoint: string, + filters: FiltersState, + options?: { + enabled?: boolean; + refetchInterval?: number | false; + onSuccess?: (data: IssuesApiResponse<T>) => void; + onError?: (error: Error) => void; + } +) { + return useQuery({ + queryKey: [endpoint, filters], // Use endpoint and filters in the query key for caching + queryFn: () => fetchData<T>(endpoint, filters), + placeholderData: (previousData) => previousData, // Keep previous data while fetching + enabled: options?.enabled !== false, // Enabled by default unless explicitly disabled + refetchInterval: options?.refetchInterval, // Optional refetch interval + onSuccess: options?.onSuccess, + onError: options?.onError, + // Reduced stale time to ensure more frequent updates + staleTime: 30 * 1000, // 30 seconds + }); +} + +/** + * Custom hook to fetch issues data using TanStack Query, based on filter state. + * This is a specialized version of useDataQuery for issues. + * + * @param filters The current filter state + * @param options Additional query options + * @returns The TanStack Query result object for the issues query + */ +export function useIssuesQuery( + filters: FiltersState, + options?: { + enabled?: boolean; + refetchInterval?: number | false; + onSuccess?: (data: IssuesApiResponse) => void; + onError?: (error: Error) => void; + } +) { + return useDataQuery('/api/issues', filters, options); +} diff --git a/packages/components/src/ui/utils/use-unmount.tsx b/packages/components/src/ui/utils/use-unmount.tsx new file mode 100644 index 00000000..f3f63813 --- /dev/null +++ b/packages/components/src/ui/utils/use-unmount.tsx @@ -0,0 +1,14 @@ +import { useEffect, useRef } from 'react'; + +export function useUnmount(func: () => void) { + const funcRef = useRef(func); + + funcRef.current = func; + + useEffect( + () => () => { + funcRef.current(); + }, + [], + ); +} diff --git a/yarn.lock b/yarn.lock index bd60f85e..da31854a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1693,21 +1693,24 @@ __metadata: "@hookform/resolvers": "npm:^3.9.1" "@radix-ui/react-alert-dialog": "npm:^1.1.4" "@radix-ui/react-avatar": "npm:^1.1.2" - "@radix-ui/react-checkbox": "npm:^1.1.3" - "@radix-ui/react-dialog": "npm:^1.1.4" - "@radix-ui/react-dropdown-menu": "npm:^2.1.4" + "@radix-ui/react-checkbox": "npm:^1.3.1" + "@radix-ui/react-dialog": "npm:^1.1.13" + "@radix-ui/react-dropdown-menu": "npm:^2.1.14" "@radix-ui/react-icons": "npm:^1.3.2" - "@radix-ui/react-label": "npm:^2.1.1" - "@radix-ui/react-popover": "npm:^1.1.4" + "@radix-ui/react-label": "npm:^2.1.6" + "@radix-ui/react-popover": "npm:^1.1.13" "@radix-ui/react-radio-group": "npm:^1.2.2" "@radix-ui/react-scroll-area": "npm:^1.2.2" - "@radix-ui/react-separator": "npm:^1.1.2" - "@radix-ui/react-slot": "npm:^1.1.2" + "@radix-ui/react-separator": "npm:^1.1.6" + "@radix-ui/react-slider": "npm:^1.3.4" + "@radix-ui/react-slot": "npm:^1.2.2" "@radix-ui/react-switch": "npm:^1.1.2" + "@radix-ui/react-tabs": "npm:^1.1.11" "@radix-ui/react-tooltip": "npm:^1.1.6" "@react-router/dev": "npm:^7.0.0" "@react-router/node": "npm:^7.0.0" - "@tanstack/react-table": "npm:^8.21.2" + "@tanstack/react-query": "npm:^5.75.2" + "@tanstack/react-table": "npm:^8.21.3" "@types/glob": "npm:^8.1.0" "@types/react": "npm:^19.0.0" "@typescript-eslint/eslint-plugin": "npm:^6.21.0" @@ -1722,6 +1725,7 @@ __metadata: input-otp: "npm:^1.4.1" lucide-react: "npm:^0.468.0" next-themes: "npm:^0.4.4" + nuqs: "npm:^2.4.3" react: "npm:^19.0.0" react-day-picker: "npm:8.10.1" react-hook-form: "npm:^7.53.1" @@ -1736,7 +1740,7 @@ __metadata: vite: "npm:^5.4.11" vite-plugin-dts: "npm:^4.4.0" vite-tsconfig-paths: "npm:^5.1.4" - zod: "npm:^3.24.1" + zod: "npm:^3.24.4" peerDependencies: react: ^19.0.0 remix-hook-form: 7.0.0 @@ -2005,6 +2009,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-arrow@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-arrow@npm:1.1.6" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/7a17b719d38e9013dc9e7eafd24786d3bc890d84fa5f092a567d014429a26d3c10777ae41db6dc080980d9f8b3bad2d625ce6e0a370cf533da59607d97e45757 + languageName: node + linkType: hard + "@radix-ui/react-avatar@npm:^1.1.2": version: 1.1.7 resolution: "@radix-ui/react-avatar@npm:1.1.7" @@ -2028,15 +2051,15 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-checkbox@npm:^1.1.3": - version: 1.2.3 - resolution: "@radix-ui/react-checkbox@npm:1.2.3" +"@radix-ui/react-checkbox@npm:^1.3.1": + version: 1.3.1 + resolution: "@radix-ui/react-checkbox@npm:1.3.1" dependencies: "@radix-ui/primitive": "npm:1.1.2" "@radix-ui/react-compose-refs": "npm:1.1.2" "@radix-ui/react-context": "npm:1.1.2" "@radix-ui/react-presence": "npm:1.1.4" - "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-primitive": "npm:2.1.2" "@radix-ui/react-use-controllable-state": "npm:1.2.2" "@radix-ui/react-use-previous": "npm:1.1.1" "@radix-ui/react-use-size": "npm:1.1.1" @@ -2050,7 +2073,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/bd589957e56da325b73199e4adeae11271ddbb21f9f88b98b8e0870254d746091f7b5c97c41bb03b188ddbf08300c69118ddbe250869bebe35b8eaa20a14f0c2 + checksum: 10c0/e2360ce7c0d894f196e0a4aaa7fe7482a5f1ca035e62d5bee364c6828d30a6024c3de4ea0ed77067489ccab1a583d09f23211ddeda78fa092e09a2026035b86c languageName: node linkType: hard @@ -2076,6 +2099,28 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-collection@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-collection@npm:1.1.6" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-slot": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/eb3faf1cdc55d0dca7bc0567254a5f4c0ee271a836a1d89a68f36950f12bbd10260b039722c46af7449a8282d833d5afcd6b7745da27be72662ffb0d4108211c + languageName: node + linkType: hard + "@radix-ui/react-compose-refs@npm:1.1.2, @radix-ui/react-compose-refs@npm:^1.1.1": version: 1.1.2 resolution: "@radix-ui/react-compose-refs@npm:1.1.2" @@ -2102,7 +2147,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dialog@npm:1.1.11, @radix-ui/react-dialog@npm:^1.1.4, @radix-ui/react-dialog@npm:^1.1.6": +"@radix-ui/react-dialog@npm:1.1.11, @radix-ui/react-dialog@npm:^1.1.6": version: 1.1.11 resolution: "@radix-ui/react-dialog@npm:1.1.11" dependencies: @@ -2134,6 +2179,38 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-dialog@npm:^1.1.13": + version: 1.1.13 + resolution: "@radix-ui/react-dialog@npm:1.1.13" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.9" + "@radix-ui/react-focus-guards": "npm:1.1.2" + "@radix-ui/react-focus-scope": "npm:1.1.6" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-portal": "npm:1.1.8" + "@radix-ui/react-presence": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-slot": "npm:1.2.2" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.6.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/7a5c2ca98eb5a4de8028e4f284790d2db470af6a814a6cb7bd20a6841c1ab8ec98f3a089e952cff9ed7c83be8cad4f99143bd1f037712dba080baac9013baadb + languageName: node + linkType: hard + "@radix-ui/react-direction@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-direction@npm:1.1.1" @@ -2170,16 +2247,39 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dropdown-menu@npm:^2.1.4": - version: 2.1.12 - resolution: "@radix-ui/react-dropdown-menu@npm:2.1.12" +"@radix-ui/react-dismissable-layer@npm:1.1.9": + version: 1.1.9 + resolution: "@radix-ui/react-dismissable-layer@npm:1.1.9" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-escape-keydown": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/945332ce097e86ac6904b12012ac9c4bb8b539688752f43c25de911fce2dd68b4f0a45b31df6eb8d038246ec0be897af988fef90ad9e12db126e93736dfa8b76 + languageName: node + linkType: hard + +"@radix-ui/react-dropdown-menu@npm:^2.1.14": + version: 2.1.14 + resolution: "@radix-ui/react-dropdown-menu@npm:2.1.14" dependencies: "@radix-ui/primitive": "npm:1.1.2" "@radix-ui/react-compose-refs": "npm:1.1.2" "@radix-ui/react-context": "npm:1.1.2" "@radix-ui/react-id": "npm:1.1.1" - "@radix-ui/react-menu": "npm:2.1.12" - "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-menu": "npm:2.1.14" + "@radix-ui/react-primitive": "npm:2.1.2" "@radix-ui/react-use-controllable-state": "npm:1.2.2" peerDependencies: "@types/react": "*" @@ -2191,7 +2291,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/1a02ff19d580672c815d4e682211be42fe86aad4fb7ca44d4d093232f2436e9e80a127a6ead7f469655bf36d68b3dfa915c45dbb833f783dfe7fcc0502ac05d0 + checksum: 10c0/c590fff74c2ac1022abc3b6e87fed922d34db7513488119a212eb23a1ae8951d2be45f0124dde7b092f038e452f890d0d279b9da24311aebdb652e650dfce074 languageName: node linkType: hard @@ -2229,6 +2329,27 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-focus-scope@npm:1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-focus-scope@npm:1.1.6" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/c4a3d12e2c45908113e3a2b9bd59666c2bcc40bde611133a5d67c8d248ddd7bfdfee66c7150dceb1acc6b894ebd44da1b08fab116bbf00fb7bb047be1ec0ec8d + languageName: node + linkType: hard + "@radix-ui/react-icons@npm:^1.3.2": version: 1.3.2 resolution: "@radix-ui/react-icons@npm:1.3.2" @@ -2253,11 +2374,11 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-label@npm:^2.1.1": - version: 2.1.4 - resolution: "@radix-ui/react-label@npm:2.1.4" +"@radix-ui/react-label@npm:^2.1.6": + version: 2.1.6 + resolution: "@radix-ui/react-label@npm:2.1.6" dependencies: - "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-primitive": "npm:2.1.2" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2268,29 +2389,29 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/700f5907492c16718e8bd8cf7d05fb9b5797f0d6b6a3fe9783d63e1d0e50320263f9107af415ca105b165d4245b6489f965902b53f8cc82288fa19c18f8b23c6 + checksum: 10c0/1c94bd363b965aeeb6010539399da4bb894c29bcb777d11f6e9a0ab22c43621be59529f1a23cfbda1f3c0ba3d8a6fdd2a50200b6e9b5839a3fbf0c2299de163e languageName: node linkType: hard -"@radix-ui/react-menu@npm:2.1.12": - version: 2.1.12 - resolution: "@radix-ui/react-menu@npm:2.1.12" +"@radix-ui/react-menu@npm:2.1.14": + version: 2.1.14 + resolution: "@radix-ui/react-menu@npm:2.1.14" dependencies: "@radix-ui/primitive": "npm:1.1.2" - "@radix-ui/react-collection": "npm:1.1.4" + "@radix-ui/react-collection": "npm:1.1.6" "@radix-ui/react-compose-refs": "npm:1.1.2" "@radix-ui/react-context": "npm:1.1.2" "@radix-ui/react-direction": "npm:1.1.1" - "@radix-ui/react-dismissable-layer": "npm:1.1.7" + "@radix-ui/react-dismissable-layer": "npm:1.1.9" "@radix-ui/react-focus-guards": "npm:1.1.2" - "@radix-ui/react-focus-scope": "npm:1.1.4" + "@radix-ui/react-focus-scope": "npm:1.1.6" "@radix-ui/react-id": "npm:1.1.1" - "@radix-ui/react-popper": "npm:1.2.4" - "@radix-ui/react-portal": "npm:1.1.6" + "@radix-ui/react-popper": "npm:1.2.6" + "@radix-ui/react-portal": "npm:1.1.8" "@radix-ui/react-presence": "npm:1.1.4" - "@radix-ui/react-primitive": "npm:2.1.0" - "@radix-ui/react-roving-focus": "npm:1.1.7" - "@radix-ui/react-slot": "npm:1.2.0" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-roving-focus": "npm:1.1.9" + "@radix-ui/react-slot": "npm:1.2.2" "@radix-ui/react-use-callback-ref": "npm:1.1.1" aria-hidden: "npm:^1.2.4" react-remove-scroll: "npm:^2.6.3" @@ -2304,26 +2425,26 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/fad42d6b999954b655878c78ea401e7f06d36d22f0213cd9f66e91bca31c8891447ca66021a5a7bce36f45dfa4100aaa3e8be74715338849dd9cae3c000d2546 + checksum: 10c0/88d5fd986b8d56ce587109507d272f726fd6f45c42886559c1240e868890fc91e3f15e889b69a5db23851c6b576f606cb80640ada6b728261073ee6914fcb422 languageName: node linkType: hard -"@radix-ui/react-popover@npm:^1.1.4": - version: 1.1.11 - resolution: "@radix-ui/react-popover@npm:1.1.11" +"@radix-ui/react-popover@npm:^1.1.13": + version: 1.1.13 + resolution: "@radix-ui/react-popover@npm:1.1.13" dependencies: "@radix-ui/primitive": "npm:1.1.2" "@radix-ui/react-compose-refs": "npm:1.1.2" "@radix-ui/react-context": "npm:1.1.2" - "@radix-ui/react-dismissable-layer": "npm:1.1.7" + "@radix-ui/react-dismissable-layer": "npm:1.1.9" "@radix-ui/react-focus-guards": "npm:1.1.2" - "@radix-ui/react-focus-scope": "npm:1.1.4" + "@radix-ui/react-focus-scope": "npm:1.1.6" "@radix-ui/react-id": "npm:1.1.1" - "@radix-ui/react-popper": "npm:1.2.4" - "@radix-ui/react-portal": "npm:1.1.6" + "@radix-ui/react-popper": "npm:1.2.6" + "@radix-ui/react-portal": "npm:1.1.8" "@radix-ui/react-presence": "npm:1.1.4" - "@radix-ui/react-primitive": "npm:2.1.0" - "@radix-ui/react-slot": "npm:1.2.0" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-slot": "npm:1.2.2" "@radix-ui/react-use-controllable-state": "npm:1.2.2" aria-hidden: "npm:^1.2.4" react-remove-scroll: "npm:^2.6.3" @@ -2337,7 +2458,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/0d15550d9127726b5a815ce04e51e3ccdf80f696e484d0b4a1683e6349600c4d62369759d612f429866d37e314b6fec41a0eebdc1c66ffd894affe63bee95a72 + checksum: 10c0/22a0ab372a741b2db9d48e5251104a2c48f6d246c107409a11c7c237dc68a3850d1e7fb626fac79c008958b8067a8c9428e3d0bf8c58b88977494a1c0106a5e3 languageName: node linkType: hard @@ -2369,6 +2490,34 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-popper@npm:1.2.6": + version: 1.2.6 + resolution: "@radix-ui/react-popper@npm:1.2.6" + dependencies: + "@floating-ui/react-dom": "npm:^2.0.0" + "@radix-ui/react-arrow": "npm:1.1.6" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + "@radix-ui/react-use-rect": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + "@radix-ui/rect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/b166c609a9475ffcdc65a0fd4bb9cf2cd67e7d24240dba9b954ec97496970783966e5e9f52cf9b12aff363d24f5112970e80813cf0eb8d4a1d989afdad59e0d8 + languageName: node + linkType: hard + "@radix-ui/react-portal@npm:1.1.6": version: 1.1.6 resolution: "@radix-ui/react-portal@npm:1.1.6" @@ -2389,6 +2538,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-portal@npm:1.1.8": + version: 1.1.8 + resolution: "@radix-ui/react-portal@npm:1.1.8" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/53590f70a2b0280cab07cb1354d0061889ecff06f04f2518ef562a30b7cea67093a1d4e2d58a6338e8d004646dd72e1211a2d47e3e0b3fc2d77317d79187d2f2 + languageName: node + linkType: hard + "@radix-ui/react-presence@npm:1.1.4": version: 1.1.4 resolution: "@radix-ui/react-presence@npm:1.1.4" @@ -2428,6 +2597,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-primitive@npm:2.1.2": + version: 2.1.2 + resolution: "@radix-ui/react-primitive@npm:2.1.2" + dependencies: + "@radix-ui/react-slot": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/0c1b4b5d2f225dc85e02a915b362e38383eb3bd6d150a72cb9183485c066156caad1a9f530202b84a5ad900d365302c0843fdcabb13100808872b3655709099d + languageName: node + linkType: hard + "@radix-ui/react-radio-group@npm:^1.2.2": version: 1.3.4 resolution: "@radix-ui/react-radio-group@npm:1.3.4" @@ -2483,6 +2671,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-roving-focus@npm:1.1.9": + version: 1.1.9 + resolution: "@radix-ui/react-roving-focus@npm:1.1.9" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-collection": "npm:1.1.6" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/7794036199245d3d153f2c3f79fc0f36ba1eec81ba9dca28927c4693f3cca1ddbd3e54d60372cd506c82b826371519d9dc5280ffbe8a3cb0220e141e37718d46 + languageName: node + linkType: hard + "@radix-ui/react-scroll-area@npm:^1.2.2": version: 1.2.6 resolution: "@radix-ui/react-scroll-area@npm:1.2.6" @@ -2510,11 +2725,11 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-separator@npm:^1.1.2": - version: 1.1.4 - resolution: "@radix-ui/react-separator@npm:1.1.4" +"@radix-ui/react-separator@npm:^1.1.6": + version: 1.1.6 + resolution: "@radix-ui/react-separator@npm:1.1.6" dependencies: - "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-primitive": "npm:2.1.2" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -2525,11 +2740,40 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/79ce54baceeaff30a518bf99cc6cbc292767ee730eb20d276664791d1530e991f870440a4dbf82b93710fdd165d18be1f8e44a0bd3ffd1a0c52e49d71838e49c + checksum: 10c0/498c581d6f712a1a2a5f956fd415c41e85769b0891cf8253fcc84bacb3e344dc4c0b8fa416283b46d04d5d7511044bc41bc448591b2bb39338864f277d915d16 languageName: node linkType: hard -"@radix-ui/react-slot@npm:1.2.0, @radix-ui/react-slot@npm:^1.1.2": +"@radix-ui/react-slider@npm:^1.3.4": + version: 1.3.4 + resolution: "@radix-ui/react-slider@npm:1.3.4" + dependencies: + "@radix-ui/number": "npm:1.1.1" + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-collection": "npm:1.1.6" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/cdb1e19f91699d3f698ce1b30ff7570c62282e891e0eb098621499084fd6ac3a68e88a8ea746bd21f7f4995e5a3afec5dca5ee221dd72d44212a99d3cf399b71 + languageName: node + linkType: hard + +"@radix-ui/react-slot@npm:1.2.0": version: 1.2.0 resolution: "@radix-ui/react-slot@npm:1.2.0" dependencies: @@ -2544,6 +2788,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-slot@npm:1.2.2, @radix-ui/react-slot@npm:^1.2.2": + version: 1.2.2 + resolution: "@radix-ui/react-slot@npm:1.2.2" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/74489f5ad11b17444560a1cdd664c01308206ce5cb9fcd46121b45281ece20273948479711411223c1081f709c15409242d51ca6cc57ff81f6335d70e0cbe0b5 + languageName: node + linkType: hard + "@radix-ui/react-switch@npm:^1.1.2": version: 1.2.2 resolution: "@radix-ui/react-switch@npm:1.2.2" @@ -2569,6 +2828,32 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-tabs@npm:^1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-tabs@npm:1.1.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-presence": "npm:1.1.4" + "@radix-ui/react-primitive": "npm:2.1.2" + "@radix-ui/react-roving-focus": "npm:1.1.9" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/eebecb25f4e245c4abf0968b86bb4ee468965e4d3524d48147298715cd54a58dab8b813cd65ed12ceb2a3e1410f80097e2b0532da01de79e78fb398e002578a3 + languageName: node + linkType: hard + "@radix-ui/react-tooltip@npm:^1.1.6": version: 1.2.4 resolution: "@radix-ui/react-tooltip@npm:1.2.4" @@ -3818,7 +4103,25 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-table@npm:^8.21.2": +"@tanstack/query-core@npm:5.75.0": + version: 5.75.0 + resolution: "@tanstack/query-core@npm:5.75.0" + checksum: 10c0/1c90d5f463fe5b4dac4525279300cd950acaef670b8589ba406df833bee3bdb4dcb58fba78c9a5424e7d19a317590eecad838fb389f37a91edb781f9c90af358 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.75.2": + version: 5.75.2 + resolution: "@tanstack/react-query@npm:5.75.2" + dependencies: + "@tanstack/query-core": "npm:5.75.0" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/b265a89f9166747589ce80019505be24a43a6cffc7af09700fadb35a91d28a0da380f7e18bcf2ffb7396cd0f2ca45d3322f5f3758d3265b68533a59dad6dd487 + languageName: node + linkType: hard + +"@tanstack/react-table@npm:^8.21.3": version: 8.21.3 resolution: "@tanstack/react-table@npm:8.21.3" dependencies: @@ -8656,6 +8959,13 @@ __metadata: languageName: node linkType: hard +"mitt@npm:^3.0.1": + version: 3.0.1 + resolution: "mitt@npm:3.0.1" + checksum: 10c0/3ab4fdecf3be8c5255536faa07064d05caa3dd332bd318ff02e04621f7b3069ca1de9106cfe8e7ced675abfc2bec2ce4c4ef321c4a1bb1fb29df8ae090741913 + languageName: node + linkType: hard + "mkdirp@npm:^1.0.4": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" @@ -8869,6 +9179,30 @@ __metadata: languageName: node linkType: hard +"nuqs@npm:^2.4.3": + version: 2.4.3 + resolution: "nuqs@npm:2.4.3" + dependencies: + mitt: "npm:^3.0.1" + peerDependencies: + "@remix-run/react": ">=2" + next: ">=14.2.0" + react: ">=18.2.0 || ^19.0.0-0" + react-router: ^6 || ^7 + react-router-dom: ^6 || ^7 + peerDependenciesMeta: + "@remix-run/react": + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + checksum: 10c0/29a911749a74a1b2933942f7de7952ddf6158ec2f19c2a0a849111f894b9d0745848b84095e5e0549ad9f2690303a122392b76296e6018c3bb540b4ea68a58c8 + languageName: node + linkType: hard + "nyc@npm:^15.1.0": version: 15.1.0 resolution: "nyc@npm:15.1.0" @@ -11628,9 +11962,9 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.24.1": - version: 3.24.3 - resolution: "zod@npm:3.24.3" - checksum: 10c0/ab0369810968d0329a1a141e9418e01e5c9c2a4905cbb7cb7f5a131d6e9487596e1400e21eeff24c4a8ee28dacfa5bd6103893765c055b7a98c2006a5a4fc68d +"zod@npm:^3.24.4": + version: 3.24.4 + resolution: "zod@npm:3.24.4" + checksum: 10c0/ab3112f017562180a41a0f83d870b333677f7d6b77f106696c56894567051b91154714a088149d8387a4f50806a2520efcb666f108cd384a35c236a191186d91 languageName: node linkType: hard