├── .changeset ├── README.md └── config.json ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .vscode ├── extensions.json └── settings.template.json ├── README.md ├── package.json ├── packages ├── core │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── filter.test.ts │ │ ├── filter │ │ │ ├── field.ts │ │ │ ├── index.ts │ │ │ ├── predicate.ts │ │ │ ├── sphere.ts │ │ │ ├── types.ts │ │ │ ├── utils.test.ts │ │ │ ├── utils.ts │ │ │ ├── validation.test.ts │ │ │ └── validation.ts │ │ ├── fn-sphere.ts │ │ ├── index.ts │ │ ├── presets.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── docs │ ├── .gitignore │ ├── README.md │ ├── astro.config.ts │ ├── package.json │ ├── public │ │ └── favicon.svg │ ├── src │ │ ├── assets │ │ │ └── showcase │ │ │ │ ├── uglysearch.jpg │ │ │ │ └── yjs-inspector.png │ │ ├── components │ │ │ ├── code-wrapper.tsx │ │ │ ├── examples │ │ │ │ ├── akumatus-filter-builder.tsx │ │ │ │ ├── flatten-filter-builder.tsx │ │ │ │ ├── neo-uglysearch │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── theme.tsx │ │ │ │ │ └── transform.ts │ │ │ │ └── utils.ts │ │ │ ├── fluid-grid.astro │ │ │ ├── intro-example │ │ │ │ ├── code.tsx │ │ │ │ └── utils.ts │ │ │ ├── live-code-tab-layout.astro │ │ │ ├── media-card.astro │ │ │ ├── mui-theme-wrapper.tsx │ │ │ ├── package-manager-tabs.astro │ │ │ ├── showcase-card.astro │ │ │ ├── showcase-sites.astro │ │ │ ├── source-code-card.astro │ │ │ ├── static-preview-code.astro │ │ │ ├── table.tsx │ │ │ └── theme-anatomy.tsx │ │ ├── content │ │ │ ├── config.ts │ │ │ └── docs │ │ │ │ ├── customization │ │ │ │ ├── data-input.mdx │ │ │ │ ├── filter.mdx │ │ │ │ ├── localization.mdx │ │ │ │ └── theme.mdx │ │ │ │ ├── guides │ │ │ │ ├── getting-started.mdx │ │ │ │ └── introduction.mdx │ │ │ │ ├── index.mdx │ │ │ │ └── reference │ │ │ │ └── example.mdx │ │ ├── env.d.ts │ │ ├── pages │ │ │ └── changelog.astro │ │ └── styles │ │ │ ├── custom.css │ │ │ └── tailwind.css │ ├── tailwind.config.mjs │ └── tsconfig.json ├── filter │ ├── CHANGELOG.md │ ├── README.md │ ├── eslint.config.mjs │ ├── locales.d.ts │ ├── locales.js │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── hooks.test.tsx │ │ ├── filter-map.test.ts │ │ ├── filter-map.ts │ │ ├── filter-sphere-provider.tsx │ │ ├── hooks │ │ │ ├── use-filter-group.ts │ │ │ ├── use-filter-rule.ts │ │ │ ├── use-filter-schema-context.tsx │ │ │ ├── use-filter-select.ts │ │ │ ├── use-filter-sphere.tsx │ │ │ └── use-root-rule.ts │ │ ├── index.ts │ │ ├── locales │ │ │ ├── en-US.ts │ │ │ ├── get-locale-text.ts │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── ja-JP.ts │ │ │ └── zh-CN.ts │ │ ├── theme │ │ │ ├── context.tsx │ │ │ ├── create-filter-theme.ts │ │ │ ├── hooks.tsx │ │ │ ├── index.ts │ │ │ ├── preset.ts │ │ │ └── types.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── views │ │ │ ├── components.tsx │ │ │ ├── data-input-views.tsx │ │ │ ├── field-select.tsx │ │ │ ├── filter-builder.tsx │ │ │ ├── filter-data-input.tsx │ │ │ ├── filter-group-container.tsx │ │ │ ├── filter-group.tsx │ │ │ ├── filter-select.tsx │ │ │ ├── primitives.tsx │ │ │ ├── rule-joiner.tsx │ │ │ ├── single-filter-container.tsx │ │ │ ├── single-filter.tsx │ │ │ └── types.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── playground │ ├── README.md │ ├── index.html │ ├── package.json │ ├── src │ │ ├── app.css │ │ ├── app.tsx │ │ ├── columns.tsx │ │ ├── filter │ │ │ ├── create-advanced-filter.ts │ │ │ ├── default-storage.ts │ │ │ ├── filter-group-container.tsx │ │ │ ├── filter-rule.tsx │ │ │ ├── flatten-filter-builder.tsx │ │ │ ├── flatten-filter-dialog.tsx │ │ │ └── use-advanced-filter.ts │ │ ├── hooks │ │ │ └── misc.ts │ │ ├── index.css │ │ ├── main.tsx │ │ ├── presets.ts │ │ ├── table.tsx │ │ ├── utils.ts │ │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── theme-mui-material │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ ├── index.ts │ └── theme.tsx │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json └── vitest.workspace.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "lawvs/fn-sphere" }], 4 | "commit": false, 5 | "linked": [["@fn-sphere/core", "@fn-sphere/filter"]], 6 | "access": "restricted", 7 | "baseBranch": "main", 8 | "updateInternalDependencies": "patch", 9 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 10 | "onlyUpdatePeerDependentsWhenOutOfRange": true 11 | }, 12 | "ignore": ["playground", "docs"] 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: npm 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | open-pull-requests-limit: 10 9 | 10 | groups: 11 | minor-and-patch: 12 | applies-to: version-updates 13 | update-types: 14 | - "patch" 15 | - "minor" 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | contents: read # to access the repository 15 | pages: write # to deploy to Pages 16 | id-token: write # to verify the deployment originates from an appropriate source 17 | 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup pnpm 23 | uses: pnpm/action-setup@v3 24 | 25 | - name: Use Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: "pnpm" 30 | 31 | - name: Install dependencies 32 | run: pnpm install 33 | 34 | - name: Type Check 35 | run: pnpm run typeCheck 36 | 37 | - name: Format Check 38 | run: pnpm run format 39 | 40 | - name: Lint 41 | run: pnpm run lint 42 | 43 | - name: Test 44 | run: pnpm run test --coverage 45 | 46 | - name: Upload test coverage 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: "coverage" 50 | path: "./coverage" 51 | if-no-files-found: "ignore" 52 | 53 | - name: Build 54 | run: pnpm run build 55 | 56 | - name: Upload docs artifacts 57 | # https://github.com/actions/upload-pages-artifact 58 | uses: actions/upload-pages-artifact@v3 59 | with: 60 | path: "./packages/docs/dist" 61 | name: "docs" 62 | 63 | - name: Upload playground artifacts 64 | # https://github.com/actions/upload-pages-artifact 65 | uses: actions/upload-pages-artifact@v3 66 | with: 67 | path: "./packages/playground/dist" 68 | name: "playground" 69 | 70 | - name: Deploy GitHub Pages 71 | if: github.ref == 'refs/heads/main' 72 | # https://github.com/actions/deploy-pages 73 | uses: actions/deploy-pages@v4 74 | with: 75 | artifact_name: "docs" 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: write-all 11 | 12 | jobs: 13 | release: 14 | name: Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repo 18 | uses: actions/checkout@v3 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v3 22 | 23 | - name: Use Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: "pnpm" 28 | 29 | - name: Install dependencies 30 | run: pnpm install 31 | 32 | - name: Create Release Pull Request or Publish to npm 33 | id: changesets 34 | uses: changesets/action@v1 35 | with: 36 | publish: "pnpm run release" 37 | title: "chore: version packages" 38 | commit: "chore: version packages" 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | NPM_CONFIG_PROVENANCE: true 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode/* 4 | !.vscode/tasks.json 5 | !.vscode/settings.template.json 6 | !.vscode/launch.template.json 7 | !.vscode/extensions.json 8 | node_modules 9 | *.tgz 10 | 11 | # code coverage 12 | /coverage 13 | /.nyc_output 14 | /.coverage 15 | 16 | # Build out 17 | dist/ 18 | build 19 | temp 20 | tsconfig.tsbuildinfo 21 | tsconfig.*.tsbuildinfo 22 | .next 23 | next-env.d.ts 24 | 25 | # Cache 26 | .eslintcache -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | package.json 4 | .astro 5 | packages/docs/src/content/docs/api 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "astro-build.astro-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnSaveMode": "file", 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": "explicit", 7 | "source.organizeImports": "explicit" 8 | }, 9 | "testing.automaticallyOpenPeekView": "never", 10 | "[json]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "files.associations": { 14 | "*.mdx": "markdown" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fn Sphere 2 | 3 | [![Build](https://github.com/lawvs/fn-sphere/actions/workflows/build.yml/badge.svg)](https://github.com/lawvs/fn-sphere/actions/workflows/build.yml) 4 | [![npm](https://img.shields.io/npm/v/@fn-sphere/filter)](https://www.npmjs.com/package/@fn-sphere/filter) 5 | 6 | Fn Sphere contains a set of libraries for filtering, sorting, and transforming data. Use it, you can easily integrate advanced filters, sorters, and transform functions to handle your data. 7 | 8 | ## Filter Sphere 9 | 10 | With Filter Sphere, you can easily integrate a filter system into your application. 11 | 12 | ![demo](https://github.com/user-attachments/assets/5a5b9ebe-f37e-4944-8bf2-e29555dff138) 13 | 14 | ![demo ui](https://github.com/user-attachments/assets/cbf689fd-029d-4f2b-8993-0363f2667e74) 15 | 16 | ## Usage 17 | 18 | Visit [Filter Sphere Docs](https://lawvs.github.io/fn-sphere) to learn more. 19 | 20 | ```sh 21 | npm add @fn-sphere/filter 22 | ``` 23 | 24 | ```tsx 25 | import { useFilterSphere } from "@fn-sphere/filter"; 26 | import { z } from "zod"; 27 | 28 | const YOUR_DATA_SCHEMA = z.object({ 29 | name: z.string(), 30 | age: z.number(), 31 | }); 32 | 33 | const YOUR_DATA: z.infer[] = [ 34 | { name: "Jack", age: 18 }, 35 | ]; 36 | 37 | const Filter = () => { 38 | const { filterRule, predicate, context } = useFilterSphere({ 39 | schema: YOUR_DATA_SCHEMA, 40 | onRuleChange: ({ predicate }) => { 41 | const filteredData = YOUR_DATA.filter(predicate); 42 | console.log(filteredData); 43 | }, 44 | }); 45 | 46 | return ( 47 | 48 | 49 | 50 | ); 51 | }; 52 | ``` 53 | 54 | ## Acknowledgements 55 | 56 | - This project is inspired by the filter system in [toeverything/AFFiNE](https://github.com/toeverything/AFFiNE/tree/3e810eb043e62811ba3ab2e021c6f4b92fb4fe70/packages/frontend/core/src/components/page-list/filter) 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.0", 3 | "private": true, 4 | "description": "", 5 | "type": "module", 6 | "author": "whitewater ", 7 | "scripts": { 8 | "dev": "pnpm --filter playground run dev", 9 | "build": "pnpm --recursive run build", 10 | "dev:docs": "pnpm --filter docs run dev", 11 | "typeCheck": "pnpm --recursive run typeCheck", 12 | "lint": "pnpm --recursive run lint", 13 | "format": "prettier --check .", 14 | "format:fix": "prettier --write .", 15 | "test": "vitest", 16 | "changeset": "changeset", 17 | "release": "pnpm run build && changeset publish" 18 | }, 19 | "prettier": {}, 20 | "devDependencies": { 21 | "@changesets/changelog-github": "^0.5.1", 22 | "@changesets/cli": "^2.28.1", 23 | "@vitest/coverage-v8": "^2.1.8", 24 | "prettier": "^3.5.3", 25 | "typescript": "^5.8.2", 26 | "vitest": "^2.1.8" 27 | }, 28 | "packageManager": "pnpm@9.6.0", 29 | "engines": { 30 | "node": ">=20" 31 | } 32 | } -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @fn-sphere/core 2 | 3 | ## 0.6.0 4 | 5 | ### Minor Changes 6 | 7 | - [#51](https://github.com/lawvs/fn-sphere/pull/51) [`a8b07f2`](https://github.com/lawvs/fn-sphere/commit/a8b07f24ed031e5ed3c4396cfdf7a603f5fb2209) Thanks [@lawvs](https://github.com/lawvs)! - Make string filter case insensitive 8 | 9 | - [#51](https://github.com/lawvs/fn-sphere/pull/51) [`a8b07f2`](https://github.com/lawvs/fn-sphere/commit/a8b07f24ed031e5ed3c4396cfdf7a603f5fb2209) Thanks [@lawvs](https://github.com/lawvs)! - Add locale supports 10 | 11 | BREAKING CHANGE 12 | 13 | - All name of filter has been changed. 14 | - The `booleanFilter` has been removed. 15 | 16 | - [`01369a8`](https://github.com/lawvs/fn-sphere/commit/01369a805b0fb644ddbb4e7dd334d7896651ac84) Thanks [@lawvs](https://github.com/lawvs)! - Breaking Changes 17 | 18 | - Removed support for array input in `defineTypedFn` and `defineGenericFn`. 19 | 20 | ### Patch Changes 21 | 22 | - [`020bdb1`](https://github.com/lawvs/fn-sphere/commit/020bdb1dbdac9e6dfcf47d01bdeeb05d6bd13612) Thanks [@lawvs](https://github.com/lawvs)! - Add new utils function `createDefaultRule` 23 | 24 | ## 0.5.0 25 | 26 | ### Patch Changes 27 | 28 | - f5eae65: Remove throw statements in filterFn 29 | - 2b17977: Support literal union in preset fn 30 | 31 | ## 0.4.0 32 | 33 | ### Patch Changes 34 | 35 | - e0f5632: Fix `isValidRule` incorrectly returned `false` for functions with `skipValidate` enabled 36 | 37 | Now, even if `skipValidate` is enabled, the input data is still checked for length. 38 | 39 | - 744b13e: Allow attaching meta to filter rule 40 | - b042713: Add countValidRules function 41 | 42 | ## 0.3.8 43 | 44 | ## 0.3.7 45 | 46 | ## 0.3.6 47 | 48 | ## 0.3.5 49 | 50 | ## 0.3.4 51 | 52 | ## 0.3.3 53 | 54 | ### Patch Changes 55 | 56 | - caeeb9c: Move `createFilterGroup` and `createSingleFilter` to core package. 57 | 58 | Add `getFilterRule` method to `createFilterSphere`. 59 | 60 | - 79abaa0: Update readme 61 | 62 | ## 0.3.2 63 | 64 | ## 0.3.1 65 | 66 | ## 0.3.0 67 | 68 | ### Patch Changes 69 | 70 | - b31b201: Rename `filterList` to `filterFnList` 71 | 72 | ## 0.2.0 73 | 74 | ## 0.1.2 75 | 76 | ### Patch Changes 77 | 78 | - 336fe84: `getParametersExceptFirst` now returns an array instead of a Zod tuple. 79 | 80 | ```ts 81 | import { getParametersExceptFirst } from "@fn-sphere/core"; 82 | 83 | const schema = { 84 | name: "test", 85 | define: z.function().args(z.number(), z.boolean()).returns(z.void()), 86 | implement: () => {}, 87 | }; 88 | 89 | isSameType(z.tuple(getParametersExceptFirst(schema)), z.tuple([z.boolean()])); 90 | // true 91 | ``` 92 | 93 | - b9d3b0a: Rename and export `FilterRule` 94 | 95 | ```ts 96 | interface SingleFilter { 97 | type: "Filter"; 98 | /** 99 | * Field path 100 | * 101 | * If it's a empty array, it means the root object. 102 | * If not provided, it means user didn't select a field. 103 | */ 104 | path?: FilterPath; 105 | /** 106 | * Filter name 107 | * 108 | * If not provided, it means user didn't select a filter. 109 | */ 110 | name?: string; 111 | /** 112 | * Arguments for the filter function 113 | */ 114 | args: unknown[]; 115 | invert?: boolean; 116 | } 117 | 118 | interface SingleFilter extends SingleFilterInput { 119 | /** 120 | * Unique id, used for tracking changes or resorting 121 | */ 122 | id: FilterId; 123 | } 124 | 125 | export interface FilterGroupInput { 126 | type: "FilterGroup"; 127 | op: "and" | "or"; 128 | conditions: (SingleFilter | FilterGroup)[]; 129 | invert?: boolean; 130 | } 131 | 132 | export interface FilterGroup extends FilterGroupInput { 133 | /** 134 | * Unique id, used for tracking changes or resorting 135 | */ 136 | id: FilterId; 137 | } 138 | 139 | export type FilterRule = SingleFilter | FilterGroup; 140 | ``` 141 | 142 | ## 0.1.0 143 | 144 | ### Minor Changes 145 | 146 | - 5c84d94: first publish 147 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @fn-sphere/core 2 | 3 | The `@fn-sphere/core` package is designed to provide a comprehensive set of utilities for working with data interactively. It offers tools for filtering, sorting, and transforming data, making it easier to handle complex data operations. 4 | 5 | ## Usages 6 | 7 | ### Filter 8 | 9 | ```ts 10 | import { 11 | createFilterSphere, 12 | findFilterableFields, 13 | createFilterPredicate, 14 | presetFilter, 15 | } from "@fn-sphere/core"; 16 | import { z } from "zod"; 17 | 18 | // Define data schema 19 | const zData = z.object({ 20 | name: z.string(), 21 | age: z.number(), 22 | address: z.object({ 23 | city: z.string(), 24 | street: z.string(), 25 | }), 26 | }); 27 | 28 | type Data = z.infer; 29 | 30 | // Get all filterable fields 31 | const availableFields = findFilterableFields({ 32 | schema, 33 | filterFnList, 34 | maxDeep, 35 | }); 36 | console.log(availableFields); 37 | 38 | const firstField = fields[0]; 39 | const availableFilter = firstField.filterFnList; 40 | const firstFilterSchema = availableFilter[0]; 41 | console.log(firstFilterSchema); 42 | 43 | const requiredParameters = getParametersExceptFirst(firstFilterSchema); 44 | console.log(requiredParameters); 45 | 46 | // Create a filter rule for specific field 47 | const filterRule = createSingleFilter({ 48 | name: firstFilterSchema.name, 49 | path: firstField.path, 50 | args: [INPUT_VALUE], 51 | }); 52 | 53 | const predicate = createFilterPredicate({ 54 | schema: zData, 55 | filterFnList: presetFilter, 56 | filterRule, 57 | }); 58 | 59 | // Filter data 60 | const filterData = data.filter(predicate); 61 | console.log(filterData); 62 | ``` 63 | 64 | ## API 65 | 66 | - `createFilterSphere`: Creates a filter sphere with the given schema and filters. 67 | - `findFilterableFields`: Finds the filterable fields in the given schema. 68 | - `createFilterPredicate`: Creates a filter predicate with the given filter sphere and filter rule. 69 | - `presetFilter`: A preset filter function that can be used to filter data. 70 | 71 | ## Types 72 | 73 | ```ts 74 | // FilterSphere 75 | 76 | const findFilterableFields: ({ 77 | schema, 78 | filterFnList, 79 | maxDeep, 80 | }: { 81 | schema: ZodType; 82 | filterFnList: FnSchema[]; 83 | maxDeep?: number; 84 | }) => FilterField[]; 85 | 86 | const createFilterPredicate: ({ 87 | schema, 88 | filterFnList, 89 | filterRule, 90 | }: { 91 | /** 92 | * The schema of the data. 93 | */ 94 | schema: z.ZodType; 95 | filterFnList: FnSchema[]; 96 | /** 97 | * The filter rule. 98 | */ 99 | filterRule?: FilterRule; 100 | }) => (data: Data) => boolean; 101 | 102 | type FilterField = { 103 | /** 104 | * If it's a empty array, it means the root object 105 | */ 106 | path: FilterPath; 107 | fieldSchema: ZodType; 108 | filterFnList: StandardFnSchema[]; 109 | }; 110 | 111 | // Filter 112 | 113 | interface SingleFilterInput { 114 | /** 115 | * Field path 116 | * 117 | * If it's a empty array, it means the root object. 118 | * If not provided, it means user didn't select a field. 119 | */ 120 | path?: FilterPath; 121 | /** 122 | * Filter name 123 | * 124 | * If not provided, it means user didn't select a filter. 125 | */ 126 | name?: string; 127 | /** 128 | * Arguments for the filter function 129 | */ 130 | args: unknown[]; 131 | invert?: boolean; 132 | } 133 | 134 | interface SingleFilter extends SingleFilterInput { 135 | type: "Filter"; 136 | /** 137 | * Unique id, used for tracking changes or resorting 138 | */ 139 | id: FilterId; 140 | } 141 | 142 | interface FilterGroupInput { 143 | op: "and" | "or"; 144 | conditions: (SingleFilter | FilterGroup)[]; 145 | invert?: boolean; 146 | } 147 | 148 | interface FilterGroup extends FilterGroupInput { 149 | type: "FilterGroup"; 150 | /** 151 | * Unique id, used for tracking changes or resorting 152 | */ 153 | id: FilterId; 154 | } 155 | 156 | type FilterRule = SingleFilter | FilterGroup; 157 | ``` 158 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fn-sphere/core", 3 | "version": "0.6.0", 4 | "description": "", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git@github.com:lawvs/fn-sphere.git", 9 | "directory": "packages/core" 10 | }, 11 | "author": "whitewater ", 12 | "sideEffects": false, 13 | "scripts": { 14 | "build": "tsc", 15 | "typeCheck": "tsc --noEmit", 16 | "test": "vitest", 17 | "clean": "rm -rf dist" 18 | }, 19 | "keywords": [], 20 | "dependencies": { 21 | "zod-compare": "^1.0.0" 22 | }, 23 | "devDependencies": { 24 | "zod": "3.24.2" 25 | }, 26 | "peerDependencies": { 27 | "zod": "^3.0.0" 28 | }, 29 | "exports": "./src/index.ts", 30 | "publishConfig": { 31 | "access": "public", 32 | "main": "dist/index.js", 33 | "types": "dist/index.d.ts", 34 | "exports": { 35 | ".": { 36 | "types": "./dist/index.d.ts", 37 | "import": "./dist/index.js" 38 | }, 39 | "./package.json": "./package.json" 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /packages/core/src/filter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { z } from "zod"; 3 | import { createFilterSphere } from "./filter/index.js"; 4 | import { 5 | createFilterGroup, 6 | getParametersExceptFirst, 7 | isEqualPath, 8 | } from "./filter/utils.js"; 9 | 10 | test("basic usage", () => { 11 | const zData = z.object({ 12 | id: z.string(), 13 | name: z.string(), 14 | age: z 15 | .number() 16 | // limit will be ignored 17 | .min(0), 18 | }); 19 | 20 | type Data = z.infer; 21 | 22 | const filterSphere = createFilterSphere(zData, [ 23 | { 24 | name: "is admin", 25 | define: z.function().args(zData).returns(z.boolean()), 26 | implement: (value) => value.id === "admin", 27 | }, 28 | { 29 | name: "number equal", 30 | define: z.function().args(z.number(), z.number()).returns(z.boolean()), 31 | implement: (value, target) => value === target, 32 | }, 33 | ]); 34 | 35 | const fields = filterSphere.findFilterableField(); 36 | expect(fields).toHaveLength(2); 37 | expect(fields.map((i) => i.path)).toEqual([[], ["age"]]); 38 | 39 | const firstField = fields[0]; 40 | if (!firstField) throw new Error("firstField is undefined"); 41 | const availableFilter = firstField.filterFnList; 42 | expect(availableFilter).toHaveLength(1); 43 | 44 | const firstFilter = availableFilter[0]; 45 | if (!firstFilter) throw new Error("firstFilter is undefined"); 46 | expect(firstFilter.name).toEqual("is admin"); 47 | const requiredParameters = getParametersExceptFirst(firstFilter); 48 | expect(requiredParameters).toHaveLength(0); 49 | 50 | const data: Data[] = [ 51 | { 52 | id: "admin", 53 | name: "name", 54 | age: 18, 55 | }, 56 | { 57 | id: "other", 58 | name: "name", 59 | age: 18, 60 | }, 61 | ]; 62 | 63 | const rule = filterSphere.getFilterRule(firstField, firstFilter); 64 | 65 | const filterData = filterSphere.filterData(data, rule); 66 | 67 | expect(filterData).toHaveLength(1); 68 | expect(filterData[0]?.id).toEqual("admin"); 69 | }); 70 | 71 | test("filter nested obj", () => { 72 | const zData = z.object({ 73 | name: z.string(), 74 | age: z.number(), 75 | }); 76 | 77 | type Data = z.infer; 78 | 79 | const filterSphere = createFilterSphere(zData, [ 80 | { 81 | name: "number equal", 82 | define: z.function().args(z.number(), z.number()).returns(z.boolean()), 83 | implement: (value, target) => value === target, 84 | }, 85 | ]); 86 | 87 | const fields = filterSphere.findFilterableField(); 88 | expect(fields).toHaveLength(1); 89 | expect(fields.map((i) => i.path)).toEqual([["age"]]); 90 | 91 | const firstField = fields[0]; 92 | if (!firstField) throw new Error("firstField is undefined"); 93 | const availableFilter = firstField.filterFnList; 94 | expect(availableFilter).toHaveLength(1); 95 | 96 | const firstFilterSchema = availableFilter[0]; 97 | if (!firstFilterSchema) throw new Error("firstFilterSchema is undefined"); 98 | expect(firstFilterSchema.name).toEqual("number equal"); 99 | const requiredParameters = getParametersExceptFirst(firstFilterSchema); 100 | expect(requiredParameters).toHaveLength(1); 101 | 102 | const data: Data[] = [ 103 | { 104 | name: "Alice", 105 | age: 18, 106 | }, 107 | { 108 | name: "Bob", 109 | age: 19, 110 | }, 111 | ]; 112 | 113 | const rule = filterSphere.getFilterRule(firstField, firstFilterSchema, [19]); 114 | 115 | expect(rule.name).toEqual("number equal"); 116 | 117 | const filterData = filterSphere.filterData(data, rule); 118 | 119 | expect(filterData).toHaveLength(1); 120 | expect(filterData[0]?.age).toEqual(19); 121 | }); 122 | 123 | test("FilterGroup usage", () => { 124 | const zData = z.object({ 125 | name: z.string(), 126 | age: z.number(), 127 | }); 128 | 129 | type Data = z.infer; 130 | 131 | const filterSphere = createFilterSphere(zData, [ 132 | { 133 | name: "number equal", 134 | define: z.function().args(z.number(), z.number()).returns(z.boolean()), 135 | implement: (value, target) => value === target, 136 | }, 137 | { 138 | name: "string equal", 139 | define: z.function().args(z.string(), z.string()).returns(z.boolean()), 140 | implement: (value, target) => value === target, 141 | }, 142 | ]); 143 | 144 | const fields = filterSphere.findFilterableField(); 145 | const ageField = fields.find((i) => isEqualPath(i.path, ["age"]))!; 146 | const nameField = fields.find((i) => isEqualPath(i.path, ["name"]))!; 147 | 148 | const ageFilter = ageField.filterFnList.find( 149 | (i) => i.name === "number equal", 150 | )!; 151 | const nameFilter = nameField.filterFnList.find( 152 | (i) => i.name === "string equal", 153 | )!; 154 | 155 | const filterGroup = createFilterGroup({ 156 | op: "and", 157 | conditions: [ 158 | filterSphere.getFilterRule(nameField, nameFilter, ["Alice"]), 159 | filterSphere.getFilterRule(ageField, ageFilter, [19]), 160 | ], 161 | }); 162 | 163 | const data: Data[] = [ 164 | { 165 | name: "Alice", 166 | age: 19, 167 | }, 168 | { 169 | name: "Bob", 170 | age: 19, 171 | }, 172 | { 173 | name: "Carol", 174 | age: 18, 175 | }, 176 | ]; 177 | 178 | const filterData = filterSphere.filterData(data, filterGroup); 179 | 180 | expect(filterData).toHaveLength(1); 181 | expect(filterData[0]?.name).toEqual("Alice"); 182 | expect(filterData[0]?.age).toEqual(19); 183 | 184 | const orGroup = createFilterGroup({ 185 | op: "or", 186 | conditions: [ 187 | filterSphere.getFilterRule(nameField, nameFilter, ["Bob"]), 188 | filterSphere.getFilterRule(ageField, ageFilter, [18]), 189 | ], 190 | }); 191 | const orFilterData = filterSphere.filterData(data, orGroup); 192 | 193 | expect(orFilterData).toHaveLength(2); 194 | expect(orFilterData[0]?.name).toEqual("Bob"); 195 | expect(orFilterData[0]?.age).toEqual(19); 196 | expect(orFilterData[1]?.name).toEqual("Carol"); 197 | expect(orFilterData[1]?.age).toEqual(18); 198 | }); 199 | -------------------------------------------------------------------------------- /packages/core/src/filter/field.ts: -------------------------------------------------------------------------------- 1 | import type { ZodType } from "zod"; 2 | import { z } from "zod"; 3 | import { isSameType } from "zod-compare"; 4 | import type { FnSchema, StandardFnSchema } from "../types.js"; 5 | import { isGenericFilter } from "../utils.js"; 6 | import type { FilterField, FilterPath } from "./types.js"; 7 | import { instantiateGenericFn } from "./utils.js"; 8 | 9 | const bfsSchemaField = ( 10 | schema: z.ZodType, 11 | maxDeep: number, 12 | walk: (field: z.ZodSchema, path: FilterPath) => void, 13 | ) => { 14 | const queue = [ 15 | { 16 | schema, 17 | path: [] as FilterPath, 18 | deep: 0, 19 | }, 20 | ]; 21 | while (queue.length > 0) { 22 | const current = queue.shift(); 23 | if (!current) break; 24 | if (current.deep > maxDeep) { 25 | break; 26 | } 27 | walk(current.schema, current.path); 28 | 29 | if (!(current.schema instanceof z.ZodObject)) { 30 | continue; 31 | } 32 | const fields = current.schema.shape; 33 | for (const key in fields) { 34 | const field = fields[key]; 35 | queue.push({ 36 | schema: field, 37 | path: [...current.path, key] as FilterPath, 38 | deep: current.deep + 1, 39 | }); 40 | } 41 | } 42 | }; 43 | 44 | /** 45 | * Find all fields that can be filtered based on the given schema and filterFnList. 46 | */ 47 | export const findFilterableFields = ({ 48 | schema, 49 | filterFnList, 50 | maxDeep = 1, 51 | }: { 52 | schema: ZodType; 53 | filterFnList: FnSchema[]; 54 | maxDeep?: number; 55 | }): FilterField[] => { 56 | const result: FilterField[] = []; 57 | 58 | const walk = (fieldSchema: ZodType, path: FilterPath) => { 59 | const instantiationFilter: StandardFnSchema[] = filterFnList 60 | .map((fnSchema): StandardFnSchema | undefined => { 61 | if (!isGenericFilter(fnSchema)) { 62 | // Standard filter 63 | return fnSchema; 64 | } 65 | const genericFilter = fnSchema; 66 | return instantiateGenericFn(fieldSchema, genericFilter); 67 | }) 68 | .filter((fn): fn is StandardFnSchema => !!fn); 69 | 70 | const availableFilter = instantiationFilter.filter((filter) => { 71 | const { define } = filter; 72 | const parameters = define.parameters(); 73 | const firstFnParameter: ZodType = parameters.items[0]; 74 | // TODO use isCompatibleType 75 | if ( 76 | firstFnParameter instanceof z.ZodAny || 77 | isSameType(fieldSchema, firstFnParameter) 78 | ) { 79 | return true; 80 | } 81 | }); 82 | 83 | if (availableFilter.length > 0) { 84 | result.push({ 85 | path, 86 | fieldSchema, 87 | filterFnList: availableFilter, 88 | }); 89 | } 90 | }; 91 | 92 | bfsSchemaField(schema, maxDeep, walk); 93 | return result; 94 | }; 95 | -------------------------------------------------------------------------------- /packages/core/src/filter/index.ts: -------------------------------------------------------------------------------- 1 | import type { createFilterSphere } from "./sphere.js"; 2 | 3 | export { findFilterableFields } from "./field.js"; 4 | export { createFilterPredicate } from "./predicate.js"; 5 | export { createFilterSphere } from "./sphere.js"; 6 | export type * from "./types.js"; 7 | export { 8 | countNumberOfRules, 9 | countValidRules, 10 | createDefaultRule, 11 | createFilterGroup, 12 | createSingleFilter, 13 | genFilterId, 14 | getParametersExceptFirst, 15 | isEqualPath, 16 | } from "./utils.js"; 17 | export { isValidRule, normalizeFilter } from "./validation.js"; 18 | export type FilterSphere = ReturnType; 19 | -------------------------------------------------------------------------------- /packages/core/src/filter/predicate.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import type { FnSchema } from "../types.js"; 3 | import { unreachable } from "../utils.js"; 4 | import type { 5 | FilterRule, 6 | StrictFilterGroup, 7 | StrictSingleFilter, 8 | } from "./types.js"; 9 | import { and, getValueAtPath, or } from "./utils.js"; 10 | import { getRuleFilterSchema, normalizeFilter } from "./validation.js"; 11 | 12 | type FilterPredicateOptions = { 13 | filterFnList: FnSchema[]; 14 | /** 15 | * The schema of the data. 16 | */ 17 | schema: z.ZodType; 18 | /** 19 | * The filter rule. 20 | */ 21 | filterRule?: FilterRule; 22 | }; 23 | 24 | const trueFn = () => true as const; 25 | 26 | const createSingleRulePredicate = ({ 27 | filterFnList, 28 | schema, 29 | strictSingleRule, 30 | }: Omit, "filterRule"> & { 31 | strictSingleRule: StrictSingleFilter; 32 | }): ((data: Data) => boolean) => { 33 | const filterSchema = getRuleFilterSchema({ 34 | rule: strictSingleRule, 35 | filterFnList: filterFnList, 36 | dataSchema: schema, 37 | }); 38 | if (!filterSchema) { 39 | console.error(schema, strictSingleRule); 40 | throw new Error("Failed to get filter fn schema"); 41 | } 42 | const skipValidate = filterSchema.skipValidate; 43 | // Returns a new function that automatically validates its inputs and outputs. 44 | // See https://zod.dev/?id=functions 45 | const fnWithImplement = skipValidate 46 | ? filterSchema.implement 47 | : filterSchema.define.implement(filterSchema.implement); 48 | 49 | return (data: Data): boolean => { 50 | const target = getValueAtPath(data, strictSingleRule.path); 51 | const result = fnWithImplement(target, ...strictSingleRule.args); 52 | return strictSingleRule.invert ? !result : result; 53 | }; 54 | }; 55 | 56 | const createGroupPredicate = ({ 57 | filterFnList, 58 | schema, 59 | strictGroupRule, 60 | }: Omit, "filterRule"> & { 61 | strictGroupRule: StrictFilterGroup; 62 | }): ((data: Data) => boolean) => { 63 | if (!strictGroupRule.conditions.length) { 64 | return trueFn; 65 | } 66 | const predicateList = strictGroupRule.conditions.map((condition) => { 67 | if (condition.type === "Filter") { 68 | return createSingleRulePredicate({ 69 | filterFnList, 70 | schema, 71 | strictSingleRule: condition, 72 | }); 73 | } 74 | if (condition.type === "FilterGroup") { 75 | return createGroupPredicate({ 76 | filterFnList, 77 | schema, 78 | strictGroupRule: condition, 79 | }); 80 | } 81 | unreachable(condition); 82 | }); 83 | if (strictGroupRule.op === "or") { 84 | return (data) => { 85 | const result = or<(data: Data) => boolean>(...predicateList)(data); 86 | return strictGroupRule.invert ? !result : result; 87 | }; 88 | } 89 | if (strictGroupRule.op === "and") { 90 | return (data) => { 91 | const result = and<(data: Data) => boolean>(...predicateList)(data); 92 | return strictGroupRule.invert ? !result : result; 93 | }; 94 | } 95 | unreachable(strictGroupRule.op); 96 | }; 97 | 98 | /** 99 | * Creates a filter predicate function based on the provided filter rule. 100 | */ 101 | export const createFilterPredicate = ({ 102 | filterFnList, 103 | schema, 104 | filterRule, 105 | }: FilterPredicateOptions) => { 106 | if (!filterRule) { 107 | return trueFn; 108 | } 109 | const normalizedRule = normalizeFilter({ 110 | filterFnList: filterFnList, 111 | dataSchema: schema, 112 | rule: filterRule, 113 | }); 114 | if (!normalizedRule) { 115 | // Not available rule 116 | return trueFn; 117 | } 118 | if (normalizedRule.type === "Filter") { 119 | return createSingleRulePredicate({ 120 | filterFnList, 121 | schema, 122 | strictSingleRule: normalizedRule, 123 | }); 124 | } 125 | if (normalizedRule.type === "FilterGroup") { 126 | return createGroupPredicate({ 127 | filterFnList, 128 | schema, 129 | strictGroupRule: normalizedRule, 130 | }); 131 | } 132 | unreachable(normalizedRule); 133 | }; 134 | -------------------------------------------------------------------------------- /packages/core/src/filter/sphere.ts: -------------------------------------------------------------------------------- 1 | import { z, type ZodType } from "zod"; 2 | import type { FnSchema, StandardFnSchema } from "../types.js"; 3 | import { findFilterableFields } from "./field.js"; 4 | import { createFilterPredicate } from "./predicate.js"; 5 | import type { 6 | FilterField, 7 | FilterRule, 8 | SingleFilter, 9 | SingleFilterInput, 10 | } from "./types.js"; 11 | import { createSingleFilter, getParametersExceptFirst } from "./utils.js"; 12 | 13 | export const createFilterSphere = ( 14 | dataSchema: ZodType, 15 | filterFnList: FnSchema[], 16 | ) => { 17 | const findFilterableField = ({ 18 | maxDeep = 1, 19 | }: { 20 | maxDeep?: number; 21 | } = {}) => 22 | findFilterableFields({ 23 | schema: dataSchema, 24 | filterFnList, 25 | maxDeep, 26 | }); 27 | 28 | const getFilterRule = ( 29 | filterField: FilterField, 30 | fnSchema: StandardFnSchema, 31 | input: unknown[] = [], 32 | options?: Omit, 33 | ): SingleFilter => { 34 | if (filterField.filterFnList.find((fn) => fn === fnSchema) === undefined) { 35 | throw new Error("Filter function is not allowed."); 36 | } 37 | const requiredParameters = getParametersExceptFirst(fnSchema); 38 | if (!fnSchema.skipValidate) { 39 | z.tuple(requiredParameters).parse(input); 40 | } 41 | 42 | return createSingleFilter({ 43 | path: filterField.path, 44 | name: fnSchema.name, 45 | args: input, 46 | ...options, 47 | }); 48 | }; 49 | 50 | const getFilterPredicate = (rule: FilterRule): ((data: Data) => boolean) => { 51 | return createFilterPredicate({ 52 | schema: dataSchema, 53 | filterFnList, 54 | filterRule: rule, 55 | }); 56 | }; 57 | 58 | const filterData = (data: Data[], rule: FilterRule): Data[] => { 59 | const predicate = getFilterPredicate(rule); 60 | return data.filter(predicate); 61 | }; 62 | 63 | return { 64 | findFilterableField, 65 | getFilterPredicate, 66 | getFilterRule, 67 | filterData, 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /packages/core/src/filter/types.ts: -------------------------------------------------------------------------------- 1 | import type { ZodType } from "zod"; 2 | import type { StandardFnSchema } from "../types.js"; 3 | 4 | export type FilterPath = (string | number)[]; 5 | 6 | export type FilterId = string & { 7 | // Type differentiator only. 8 | __filterId: true; 9 | }; 10 | 11 | export type FilterField = { 12 | /** 13 | * If it's a empty array, it means the root object 14 | */ 15 | path: FilterPath; 16 | fieldSchema: ZodType; 17 | filterFnList: StandardFnSchema[]; 18 | }; 19 | 20 | export interface SingleFilterInput { 21 | /** 22 | * Field path 23 | * 24 | * If it's a empty array, it means the root object. 25 | * If not provided, it means user didn't select a field. 26 | */ 27 | path?: FilterPath | undefined; 28 | /** 29 | * Filter name 30 | * 31 | * If not provided, it means user didn't select a filter. 32 | */ 33 | name?: string | undefined; 34 | /** 35 | * Arguments for the filter function 36 | */ 37 | args?: unknown[]; 38 | invert?: boolean; 39 | meta?: Record; 40 | } 41 | 42 | export interface SingleFilter extends SingleFilterInput { 43 | type: "Filter"; 44 | /** 45 | * Unique id, used for tracking changes or resorting 46 | */ 47 | id: FilterId; 48 | /** 49 | * Arguments for the filter function 50 | */ 51 | args: unknown[]; 52 | } 53 | 54 | export interface FilterGroupInput { 55 | op: "and" | "or"; 56 | conditions?: FilterRule[]; 57 | invert?: boolean; 58 | meta?: Record; 59 | } 60 | 61 | export interface FilterGroup extends FilterGroupInput { 62 | type: "FilterGroup"; 63 | /** 64 | * Unique id, used for tracking changes or resorting 65 | */ 66 | id: FilterId; 67 | conditions: FilterRule[]; 68 | } 69 | 70 | export type FilterRule = SingleFilter | FilterGroup; 71 | 72 | export type StrictSingleFilter = Readonly< 73 | Required> & { 74 | name: string; 75 | path: FilterPath; 76 | meta?: Record; 77 | } 78 | >; 79 | export type StrictFilterGroup = Readonly<{ 80 | /** 81 | * Unique id, used for tracking changes or resorting 82 | */ 83 | id: FilterId; 84 | type: "FilterGroup"; 85 | op: "and" | "or"; 86 | conditions: StrictFilterRule[]; 87 | invert: boolean; 88 | meta?: Record; 89 | }>; 90 | 91 | export type StrictFilterRule = StrictSingleFilter | StrictFilterGroup; 92 | -------------------------------------------------------------------------------- /packages/core/src/fn-sphere.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { isSameType } from "zod-compare"; 3 | import { createFilterSphere } from "./filter/index.js"; 4 | import type { 5 | GenericFnSchema, 6 | StandardFnSchema, 7 | ZodAnyFunction, 8 | } from "./types.js"; 9 | import { isFilterFn as isFilterSchema } from "./utils.js"; 10 | 11 | export function defineTypedFn< 12 | T extends z.ZodFunction, z.ZodTypeAny>, 13 | >(schema: StandardFnSchema): StandardFnSchema { 14 | return schema; 15 | } 16 | 17 | export function defineGenericFn< 18 | Generic extends z.ZodType, 19 | Fn extends z.ZodFunction, z.ZodTypeAny>, 20 | >(schemaFn: GenericFnSchema): GenericFnSchema { 21 | return schemaFn; 22 | } 23 | 24 | export const createFnSphere = () => { 25 | type FnSphereState = { 26 | fnMap: Record; 27 | genericFn: Record; 28 | }; 29 | const state: FnSphereState = { 30 | fnMap: {}, 31 | genericFn: {}, 32 | }; 33 | 34 | // TODO: supports genericFn 35 | const addFn = (fn: F) => { 36 | if (fn.name in state.fnMap || fn.name in state.genericFn) { 37 | throw new Error("Duplicate function name: " + fn.name); 38 | } 39 | state.fnMap[fn.name] = fn; 40 | }; 41 | 42 | const registerFnList = ( 43 | fnList: StandardFnSchema>[], 44 | ) => { 45 | fnList.forEach((fn) => { 46 | addFn(fn); 47 | }); 48 | }; 49 | 50 | const removeFn = (fnName: string) => { 51 | delete state.fnMap[fnName]; 52 | }; 53 | 54 | const getFn = (fnName: string) => { 55 | if (fnName in state.fnMap) { 56 | return state.fnMap[fnName]; 57 | } 58 | }; 59 | 60 | const findFn = < 61 | Input extends z.ZodTuple = z.ZodTuple, 62 | Output extends z.ZodType = z.ZodUnknown, 63 | >( 64 | maybePredicate: 65 | | { 66 | input?: Input; 67 | output?: Output; 68 | } 69 | | ((fn: StandardFnSchema) => boolean), 70 | ) => { 71 | if (typeof maybePredicate === "function") { 72 | return Object.values(state.fnMap).filter(maybePredicate); 73 | } 74 | const { input, output } = maybePredicate; 75 | const filterFn = Object.values(state.fnMap).filter((fn) => { 76 | return ( 77 | (input ? isSameType(input, fn.define.parameters()) : true) && 78 | (output ? isSameType(output, fn.define.returnType()) : true) 79 | ); 80 | }); 81 | return filterFn as StandardFnSchema>[]; 82 | }; 83 | 84 | const setupFilter = (schema: z.ZodType) => { 85 | const filterFn = findFn(isFilterSchema); 86 | const zFilter = createFilterSphere(schema, filterFn); 87 | return zFilter; 88 | }; 89 | 90 | return { 91 | _state: state, 92 | 93 | addFn, 94 | registerFnList, 95 | getFn, 96 | removeFn, 97 | findFn, 98 | 99 | setupFilter, 100 | }; 101 | }; 102 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./filter/index.js"; 2 | 3 | export { createFnSphere, defineGenericFn, defineTypedFn } from "./fn-sphere.js"; 4 | export { 5 | commonFilters, 6 | dateFilter, 7 | genericFilter, 8 | numberFilter, 9 | presetFilter, 10 | stringFilter, 11 | } from "./presets.js"; 12 | 13 | export { isSameType } from "zod-compare"; 14 | export type * from "./types.js"; 15 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { TypeOf, ZodFunction, ZodTuple, ZodType, ZodTypeAny } from "zod"; 2 | 3 | /** 4 | * @internal 5 | */ 6 | export type ZodAnyFunction = ZodFunction, ZodTypeAny>; 7 | 8 | export type StandardFnSchema = { 9 | name: string; 10 | define: T; 11 | implement: TypeOf; 12 | skipValidate?: boolean | undefined; 13 | meta?: Record; 14 | }; 15 | 16 | export type GenericFnSchema< 17 | DataType extends ZodType = any, 18 | Fn extends ZodAnyFunction = ZodAnyFunction, 19 | > = { 20 | name: string; 21 | genericLimit: (t: ZodType) => t is DataType; 22 | define: (t: DataType) => Fn; 23 | implement: TypeOf; 24 | skipValidate?: boolean; 25 | meta?: Record; 26 | }; 27 | 28 | export type FnSchema = StandardFnSchema | GenericFnSchema; 29 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { isSameType } from "zod-compare"; 3 | import type { FnSchema, GenericFnSchema, StandardFnSchema } from "./types.js"; 4 | 5 | export const isGenericFilter = ( 6 | fnSchema: FnSchema, 7 | ): fnSchema is GenericFnSchema => "genericLimit" in fnSchema; 8 | 9 | export const isFilterFn = (fn: StandardFnSchema) => { 10 | if (!(fn.define.returnType() instanceof z.ZodBoolean)) { 11 | // Filter should return boolean 12 | return false; 13 | } 14 | const parameters = fn.define.parameters(); 15 | if (parameters.items.length === 0) { 16 | // Filter should have at least one parameter 17 | return false; 18 | } 19 | return true; 20 | }; 21 | 22 | export const isCompareFn = (fn: StandardFnSchema) => { 23 | const returnType = fn.define.returnType(); 24 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort 25 | if ( 26 | !( 27 | returnType instanceof z.ZodNumber || 28 | isSameType( 29 | returnType, 30 | z.union([z.literal(-1), z.literal(0), z.literal(1)]), 31 | ) 32 | ) 33 | ) { 34 | // compareFn(a, b) return value sort order 35 | // > 0 sort a after b, e.g. [b, a] 36 | // < 0 sort a before b, e.g. [a, b] 37 | // === 0 keep original order of a and b 38 | return false; 39 | } 40 | const parameters = fn.define.parameters(); 41 | if (parameters.items.length !== 2) { 42 | // Compare should have exactly two parameters 43 | return false; 44 | } 45 | return true; 46 | }; 47 | 48 | /** 49 | * This function should never be called. If it is called, it means that the 50 | * code has reached a point that should be unreachable. 51 | * 52 | * @example 53 | * ```ts 54 | * function f(val: 'a' | 'b') { 55 | * if (val === 'a') { 56 | * return 1; 57 | * } else if (val === 'b') { 58 | * return 2; 59 | * } 60 | * unreachable(val); 61 | * ``` 62 | */ 63 | export function unreachable( 64 | val: never, 65 | message = "Unreachable code reached", 66 | ): never { 67 | console.error(message, val); 68 | throw new Error(message); 69 | } 70 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "./dist", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "declarationMap": true 9 | }, 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | // ... 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # Generated by typedoc 24 | src/content/docs/api 25 | -------------------------------------------------------------------------------- /packages/docs/README.md: -------------------------------------------------------------------------------- 1 | # Starlight Starter Kit: Basics 2 | 3 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | npm create astro@latest -- --template starlight 7 | ``` 8 | 9 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) 10 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) 11 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) 12 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) 13 | 14 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 15 | 16 | ## 🚀 Project Structure 17 | 18 | Inside of your Astro + Starlight project, you'll see the following folders and files: 19 | 20 | ``` 21 | . 22 | ├── public/ 23 | ├── src/ 24 | │ ├── assets/ 25 | │ ├── content/ 26 | │ │ ├── docs/ 27 | │ │ └── config.ts 28 | │ └── env.d.ts 29 | ├── astro.config.mjs 30 | ├── package.json 31 | └── tsconfig.json 32 | ``` 33 | 34 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. 35 | 36 | Images can be added to `src/assets/` and embedded in Markdown with a relative link. 37 | 38 | Static assets, like favicons, can be placed in the `public/` directory. 39 | 40 | ## 🧞 Commands 41 | 42 | All commands are run from the root of the project, from a terminal: 43 | 44 | | Command | Action | 45 | | :------------------------ | :----------------------------------------------- | 46 | | `npm install` | Installs dependencies | 47 | | `npm run dev` | Starts local dev server at `localhost:4321` | 48 | | `npm run build` | Build your production site to `./dist/` | 49 | | `npm run preview` | Preview your build locally, before deploying | 50 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 51 | | `npm run astro -- --help` | Get help using the Astro CLI | 52 | 53 | ## 👀 Want to learn more? 54 | 55 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). 56 | -------------------------------------------------------------------------------- /packages/docs/astro.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@astrojs/react"; 2 | import starlight from "@astrojs/starlight"; 3 | import tailwind from "@astrojs/tailwind"; 4 | import liveCode from "astro-live-code"; 5 | import relativeLinks from "astro-relative-links"; 6 | import { defineConfig } from "astro/config"; 7 | import rehypeExternalLinks from "rehype-external-links"; 8 | // https://github.com/HiDeoo/starlight-typedoc 9 | import starlightTypeDoc from "starlight-typedoc"; 10 | // https://github.com/HiDeoo/starlight-links-validator 11 | import starlightLinksValidator from "starlight-links-validator"; 12 | 13 | // https://astro.build/config 14 | export default defineConfig({ 15 | markdown: { 16 | rehypePlugins: [ 17 | // https://www.npmjs.com/package/rehype-external-links 18 | [ 19 | rehypeExternalLinks, 20 | { target: "_blank", rel: ["noopener", "noreferrer"] }, 21 | ], 22 | ], 23 | }, 24 | integrations: [ 25 | starlight({ 26 | title: "Filter Sphere", 27 | social: { 28 | github: "https://github.com/lawvs/fn-sphere", 29 | }, 30 | sidebar: [ 31 | { 32 | label: "Guides", 33 | autogenerate: { 34 | directory: "guides", 35 | }, 36 | }, 37 | { 38 | label: "Customization", 39 | autogenerate: { 40 | directory: "customization", 41 | }, 42 | }, 43 | { 44 | label: "Reference", 45 | autogenerate: { 46 | directory: "reference", 47 | }, 48 | }, 49 | // Prefer to show the API overviews in the sidebar, so we don't need this. 50 | // typeDocSidebarGroup, 51 | { 52 | label: "API", 53 | collapsed: true, 54 | autogenerate: { 55 | directory: "api", 56 | }, 57 | }, 58 | { 59 | label: "Changelog", 60 | collapsed: true, 61 | items: [ 62 | { 63 | label: "@fn-sphere/filter", 64 | link: "/changelog", 65 | }, 66 | ], 67 | }, 68 | ], 69 | customCss: [ 70 | // Relative path to your custom CSS file 71 | "~/styles/custom.css", 72 | "~/styles/tailwind.css", 73 | ], 74 | plugins: [ 75 | // Generate the documentation. 76 | starlightTypeDoc({ 77 | entryPoints: ["../filter/src/index.ts"], 78 | tsconfig: "../filter/tsconfig.json", 79 | typeDoc: { 80 | entryFileName: "index.md", 81 | }, 82 | }), 83 | starlightLinksValidator(), 84 | ], 85 | }), 86 | react(), 87 | liveCode({ 88 | wrapper: "~/components/code-wrapper.tsx", 89 | defaultProps: { 90 | "client:load": true, 91 | }, 92 | }), // Workaround for https://github.com/withastro/astro/issues/4229 93 | relativeLinks(), 94 | tailwind({ 95 | // Disable the default base styles: 96 | applyBaseStyles: false, 97 | }), 98 | ], 99 | vite: { 100 | ssr: { 101 | // Workaround for https://github.com/mui/material-ui/issues/42848 102 | noExternal: /@mui\/.*?/, 103 | }, 104 | }, 105 | }); 106 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "typeCheck": "astro check", 10 | "build": "astro check && astro build", 11 | "preview": "astro preview", 12 | "astro": "astro" 13 | }, 14 | "dependencies": { 15 | "@astrojs/check": "^0.9.4", 16 | "@astrojs/react": "^4.2.1", 17 | "@astrojs/starlight": "^0.32.2", 18 | "@astrojs/starlight-tailwind": "^3.0.0", 19 | "@astrojs/tailwind": "^6.0.2", 20 | "@types/react": "^19.0.10", 21 | "@types/react-dom": "^19.0.4", 22 | "astro": "^5.5.4", 23 | "astro-live-code": "^0.0.5", 24 | "astro-relative-links": "^0.4.2", 25 | "react": "^19.0.0", 26 | "react-dom": "^19.0.0", 27 | "rehype-external-links": "^3.0.0", 28 | "sharp": "^0.33.5", 29 | "starlight-links-validator": "^0.14.3", 30 | "starlight-typedoc": "^0.20.0", 31 | "tailwindcss": "^3.4.16", 32 | "typedoc": "^0.27.9", 33 | "typedoc-plugin-markdown": "^4.4.2" 34 | }, 35 | "devDependencies": { 36 | "@emotion/react": "^11.14.0", 37 | "@emotion/styled": "^11.14.0", 38 | "@fn-sphere/filter": "workspace:*", 39 | "@fn-sphere/theme-mui-material": "workspace:*", 40 | "@mui/material": "^7.0.2", 41 | "zod": "^3.24.2" 42 | } 43 | } -------------------------------------------------------------------------------- /packages/docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/docs/src/assets/showcase/uglysearch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/fn-sphere/1ea069913c1b1f22d38c07d39ae52b04f0a4c2ed/packages/docs/src/assets/showcase/uglysearch.jpg -------------------------------------------------------------------------------- /packages/docs/src/assets/showcase/yjs-inspector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawvs/fn-sphere/1ea069913c1b1f22d38c07d39ae52b04f0a4c2ed/packages/docs/src/assets/showcase/yjs-inspector.png -------------------------------------------------------------------------------- /packages/docs/src/components/code-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | export default function CodeWrapper({ children }: { children: ReactNode }) { 4 | return ( 5 |
15 | {children} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/docs/src/components/examples/flatten-filter-builder.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createFilterGroup, 3 | createFilterTheme, 4 | createSingleFilter, 5 | FilterBuilder, 6 | FilterSphereProvider, 7 | useFilterRule, 8 | useFilterSphere, 9 | useRootRule, 10 | useView, 11 | } from "@fn-sphere/filter"; 12 | import { schema } from "./utils"; 13 | 14 | const theme = createFilterTheme({ 15 | primitives: { 16 | button: ({ className, ...props }) => ( 17 |