- 基于 grill-me Q1-Q61 决策 - Vitest 测试与 CI workflow - middleware: parseFilters 核心实现 - component: Vue3 TransForm 基础组件 + Storybook Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
name: Node.js Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: contains(github.event.head_commit.message, 'chore') && contains(github.event.head_commit.message, '版本') && contains(github.event.head_commit.message, '更新')
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout code
|
||||
env:
|
||||
GIT_AUTH: ${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}
|
||||
run: |
|
||||
git init
|
||||
git remote add origin http://gitea:3000/windychen/trans-form-middleware.git
|
||||
AUTH_B64=$(printf '%s' "$GIT_AUTH" | base64 | tr -d '\n')
|
||||
git -c http.extraHeader="Authorization: Basic ${AUTH_B64}" fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Setup npm for Verdaccio
|
||||
run: |
|
||||
npm config set registry http://verdaccio:4873/
|
||||
npm config set //verdaccio:4873/:_authToken ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm i --registry http://verdaccio:4873/
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Publish to Verdaccio
|
||||
id: publish-verdaccio
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
PKG=$(node -p "require('./package.json').name.replace('@windychen/','')")
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://verdaccio:4873/@windychen%2f${PKG}/$VERSION || echo 404)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Verdaccio: version $VERSION already exists, skipping."
|
||||
echo "verdaccio_result=skipped|版本 ${VERSION} 已存在" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if npm publish --access public 2>&1; then
|
||||
echo "verdaccio_result=published|已发布 v${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "verdaccio_result=failed|发布失败" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Publish to Gitea
|
||||
id: publish-gitea
|
||||
env:
|
||||
GITEA_NPM_AUTH: ${{ secrets.CI_USER }}:${{ secrets.NPM_GITEA_TOKEN }}
|
||||
run: |
|
||||
AUTH_B64=$(printf '%s' "$GITEA_NPM_AUTH" | base64 | tr -d '\n')
|
||||
npm config set //gitea:3000/api/packages/windychen/npm/:_auth "$AUTH_B64"
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
PKG=$(node -p "require('./package.json').name")
|
||||
EXISTS=$(curl -s "http://gitea:3000/api/v1/packages/windychen?q=trans-form-middleware" -u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" | grep -c "\"version\":\"$VERSION\"" || true)
|
||||
if [ "$EXISTS" -gt 0 ]; then
|
||||
echo "Gitea: version $VERSION already exists, skipping."
|
||||
echo "gitea_result=skipped|版本 ${VERSION} 已存在" >> $GITHUB_OUTPUT
|
||||
else
|
||||
if npm publish --registry http://gitea:3000/api/packages/windychen/npm/ 2>&1; then
|
||||
echo "gitea_result=published|已发布 v${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "gitea_result=failed|发布失败" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Create Gitea Release
|
||||
if: always() && steps.publish-gitea.outcome == 'success'
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
EXISTS=$(curl -s "http://gitea:3000/api/v1/repos/windychen/trans-form-middleware/releases/tags/v${VERSION}" -u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" | grep -c "\"tag_name\"" || true)
|
||||
if [ "$EXISTS" = "0" ]; then
|
||||
curl -s -X POST \
|
||||
"http://gitea:3000/api/v1/repos/windychen/trans-form-middleware/releases" \
|
||||
-u "${{ secrets.CI_USER }}:${{ secrets.CI_PASSWORD }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"## trans-form-middleware v${VERSION}\",\"target_commitish\":\"main\"}"
|
||||
else
|
||||
echo "Release v${VERSION} already exists, skipping."
|
||||
fi
|
||||
|
||||
- name: Notify DingTalk
|
||||
if: always()
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
parse_result() {
|
||||
local raw="$1"
|
||||
local target="$2"
|
||||
local icon="${raw%%|*}"
|
||||
local detail="${raw#*|}"
|
||||
case "$icon" in
|
||||
published) echo "📦 ${target}: ${detail}" ;;
|
||||
skipped) echo "⏭️ ${target}: ${detail}" ;;
|
||||
failed) echo "❌ ${target}: ${detail}" ;;
|
||||
*) echo "⚠️ ${target}: ${detail}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
VERD_RESULT="${{ steps.publish-verdaccio.outputs.verdaccio_result }}"
|
||||
GITEA_RESULT="${{ steps.publish-gitea.outputs.gitea_result }}"
|
||||
|
||||
if [ -n "$VERD_RESULT" ]; then
|
||||
VERD_LINE=$(parse_result "$VERD_RESULT" "Verdaccio")
|
||||
else
|
||||
VERD_LINE="⚠️ Verdaccio: 未执行"
|
||||
fi
|
||||
|
||||
if [ -n "$GITEA_RESULT" ]; then
|
||||
GITEA_LINE=$(parse_result "$GITEA_RESULT" "Gitea")
|
||||
else
|
||||
GITEA_LINE="⚠️ Gitea: 未执行"
|
||||
fi
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
TITLE="✅ trans-form-middleware v${VERSION} 构建完成 chen-fnos"
|
||||
else
|
||||
TITLE="❌ trans-form-middleware v${VERSION} 构建失败 chen-fnos"
|
||||
fi
|
||||
|
||||
curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=${{ secrets.DINGDING_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"msgtype\":\"markdown\",\"markdown\":{\"title\":\"${TITLE}\",\"text\":\"## ${TITLE}\n\n${VERD_LINE}\n${GITEA_LINE}\n\n- 仓库: windychen/trans-form-middleware\n- 分支: ${{ github.ref_name }}\n- 提交: ${{ github.sha }}\"}}"
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,49 @@
|
||||
# trans-form-middleware — Design (Q1–Q61)
|
||||
|
||||
Status: **implementation scaffold** (v0.1.0)
|
||||
|
||||
Companion package: `@windychen/trans-form-component` (Vue 3 dynamic query form).
|
||||
|
||||
## Role
|
||||
|
||||
Receive component filter output → structured `{ field, query, op, value, logic?, values?, empty? }[]`. **No SQL generation.**
|
||||
|
||||
## Key decisions
|
||||
|
||||
| # | Decision |
|
||||
|---|----------|
|
||||
| 1–2 | Node.js pure JS; structured filters only |
|
||||
| 8 | Dual fields: `query` (code) + `op` (symbol) |
|
||||
| 14 | `mode`: pass-through (default) / flat / tree |
|
||||
| 24–25 | Zero deps; ESM only; no bundling (`src/index.js`) |
|
||||
| 38 | `empty: true` preserved by default |
|
||||
| 39 | Built-in `DEFAULT_OPERATOR_MAP`, merge via `operatorMap` |
|
||||
| 41 | Multi-function exports: `parseFilters`, `toTree`, `mapOperators`, … |
|
||||
| 44 | `strict` default false |
|
||||
| 46 | Multi-value: `value` (string) + `values` (parsed array) |
|
||||
| 49–50 | `allowedFields` required; `unknownField: 'error'` default |
|
||||
| 60 | Component emits all state flags; middleware skips `disabled` by default |
|
||||
| 61 | Also skips `hidden`; `skipEmpty` option controls empty items |
|
||||
|
||||
## Output example
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"field": "tags",
|
||||
"query": "IN",
|
||||
"op": "IN",
|
||||
"value": "a,b,c",
|
||||
"values": ["a", "b", "c"],
|
||||
"logic": "OR"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Operator map
|
||||
|
||||
`EQ→=`, `NE→<>`, `GT→>`, `GTE→>=`, `LT→<`, `LTE→<=`, `LIKE→LIKE`, `IN→IN`, `NOT_IN→NOT IN`, `BETWEEN→BETWEEN`, `IS_NULL→IS NULL`, `IS_NOT_NULL→IS NOT NULL`
|
||||
|
||||
## CI
|
||||
|
||||
Copy of windychen-utils pattern: commit message must contain `chore` + `版本` + `更新` to trigger publish.
|
||||
@@ -0,0 +1,62 @@
|
||||
# @windychen/trans-form-middleware
|
||||
|
||||
Node.js ESM library that parses filter arrays emitted by `@windychen/trans-form-component` into structured conditions. **Does not generate SQL** — ORM adapters consume the output.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @windychen/trans-form-middleware
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { parseFilters } from '@windychen/trans-form-middleware';
|
||||
|
||||
const componentOutput = [
|
||||
{ field: 'name', value: 'xx', query: 'EQ' },
|
||||
{ field: 'tags', value: 'a,b,c', query: 'IN', logic: 'OR' },
|
||||
];
|
||||
|
||||
const filters = parseFilters(componentOutput, {
|
||||
allowedFields: ['name', 'tags', 'status'],
|
||||
mode: 'pass-through',
|
||||
unknownField: 'error',
|
||||
skipEmpty: false,
|
||||
});
|
||||
```
|
||||
|
||||
See `DESIGN.md` for the full protocol and grill-me decisions (Q1–Q61).
|
||||
|
||||
## API
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `parseFilters(conditions, options)` | Main parser |
|
||||
| `mapOperators(query, operatorMap?)` | Map EQ/GT/… to op symbols |
|
||||
| `toTree(flat)` | Nest flat array by logic |
|
||||
| `splitMultiValue(query, value)` | Parse comma-separated multi-values |
|
||||
| `DEFAULT_OPERATOR_MAP` | Built-in operator mapping |
|
||||
|
||||
### parseOptions
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `allowedFields` | *(required)* | Whitelist of field names |
|
||||
| `mode` | `'pass-through'` | `'flat'` \| `'tree'` \| `'pass-through'` |
|
||||
| `unknownField` | `'error'` | `'error'` \| `'skip'` |
|
||||
| `strict` | `false` | Validate item shape |
|
||||
| `operatorMap` | `{}` | Merge/override operator mapping |
|
||||
| `skipFlags` | `['disabled','hidden']` | Skip items with these truthy flags |
|
||||
| `skipEmpty` | `false` | Skip items with `empty: true` |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"row": 6,
|
||||
"logic": "AND",
|
||||
"value-component": "input",
|
||||
"field": "name",
|
||||
"field-name": "名称",
|
||||
"query-option": [
|
||||
{ "value": "EQ", "label": "=", "desc-cn": "等于" },
|
||||
{ "value": "LIKE", "label": "~", "desc-cn": "包含" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 6,
|
||||
"logic": "AND",
|
||||
"value-component": "select",
|
||||
"field": "status",
|
||||
"field-name": "状态",
|
||||
"query-option": [
|
||||
{ "value": "EQ", "label": "=", "desc-cn": "等于" },
|
||||
{ "value": "NE", "label": "≠", "desc-cn": "不等于" }
|
||||
],
|
||||
"options": [
|
||||
{ "value": "active", "label": "启用" },
|
||||
{ "value": "inactive", "label": "停用" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 2,
|
||||
"logic": "OR",
|
||||
"value-component": "input",
|
||||
"field": "tags",
|
||||
"field-name": "标签",
|
||||
"in-ui": "tags",
|
||||
"query-option": [
|
||||
{ "value": "IN", "label": "∈", "desc-cn": "包含任一" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"row": 9,
|
||||
"logic": "OR",
|
||||
"value-component": "input",
|
||||
"field": "created",
|
||||
"field-name": "创建时间",
|
||||
"between-ui": "dual-input",
|
||||
"query-option": [
|
||||
{ "value": "BETWEEN", "label": "↔", "desc-cn": "区间" }
|
||||
]
|
||||
}
|
||||
]
|
||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@windychen/trans-form-middleware",
|
||||
"version": "0.1.0",
|
||||
"description": "Parse trans-form-component filter output into structured conditions (no SQL)",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.js"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"filter",
|
||||
"query",
|
||||
"middleware",
|
||||
"trans-form"
|
||||
],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DEFAULT_OPERATOR_MAP, mapOperators } from './operatorMap.js';
|
||||
export { splitMultiValue } from './splitMultiValue.js';
|
||||
export { toTree } from './toTree.js';
|
||||
export { parseFilters } from './parseFilters.js';
|
||||
@@ -0,0 +1,25 @@
|
||||
/** @type {Record<string, string>} */
|
||||
export const DEFAULT_OPERATOR_MAP = {
|
||||
EQ: '=',
|
||||
NE: '<>',
|
||||
GT: '>',
|
||||
GTE: '>=',
|
||||
LT: '<',
|
||||
LTE: '<=',
|
||||
LIKE: 'LIKE',
|
||||
IN: 'IN',
|
||||
NOT_IN: 'NOT IN',
|
||||
BETWEEN: 'BETWEEN',
|
||||
IS_NULL: 'IS NULL',
|
||||
IS_NOT_NULL: 'IS NOT NULL',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
* @param {Record<string, string>} [operatorMap]
|
||||
*/
|
||||
export function mapOperators(query, operatorMap = DEFAULT_OPERATOR_MAP) {
|
||||
const q = String(query).toUpperCase();
|
||||
const merged = { ...DEFAULT_OPERATOR_MAP, ...operatorMap };
|
||||
return merged[q] ?? q;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mapOperators } from './operatorMap.js';
|
||||
import { splitMultiValue } from './splitMultiValue.js';
|
||||
import { toTree } from './toTree.js';
|
||||
|
||||
const DEFAULT_SKIP_FLAGS = ['disabled', 'hidden'];
|
||||
|
||||
/**
|
||||
* @param {object} item
|
||||
* @param {string[]} skipFlags
|
||||
*/
|
||||
function shouldSkipByFlags(item, skipFlags) {
|
||||
return skipFlags.some((flag) => item[flag] === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{field?: string, value?: *, query?: string, logic?: string, empty?: boolean, disabled?: boolean, hidden?: boolean}>} conditions
|
||||
* @param {{
|
||||
* allowedFields: string[],
|
||||
* mode?: 'pass-through'|'flat'|'tree',
|
||||
* unknownField?: 'error'|'skip',
|
||||
* strict?: boolean,
|
||||
* operatorMap?: Record<string, string>,
|
||||
* skipFlags?: string[],
|
||||
* skipEmpty?: boolean,
|
||||
* }} options
|
||||
*/
|
||||
export function parseFilters(conditions, options) {
|
||||
const {
|
||||
allowedFields,
|
||||
mode = 'pass-through',
|
||||
unknownField = 'error',
|
||||
strict = false,
|
||||
operatorMap = {},
|
||||
skipFlags = DEFAULT_SKIP_FLAGS,
|
||||
skipEmpty = false,
|
||||
} = options;
|
||||
|
||||
if (!Array.isArray(allowedFields) || allowedFields.length === 0) {
|
||||
throw new Error('parseFilters: allowedFields is required');
|
||||
}
|
||||
if (!Array.isArray(conditions)) {
|
||||
if (strict) throw new Error('parseFilters: conditions must be an array');
|
||||
return mode === 'tree' ? [] : [];
|
||||
}
|
||||
|
||||
const allowed = new Set(allowedFields.map(String));
|
||||
const errors = [];
|
||||
const out = [];
|
||||
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const c = conditions[i];
|
||||
if (!c || typeof c !== 'object') {
|
||||
if (strict) errors.push({ index: i, reason: 'invalid item' });
|
||||
continue;
|
||||
}
|
||||
if (shouldSkipByFlags(c, skipFlags)) continue;
|
||||
if (skipEmpty && c.empty === true) continue;
|
||||
|
||||
if (c.field == null || c.query == null) {
|
||||
if (strict) errors.push({ index: i, reason: 'missing field or query' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = String(c.field);
|
||||
if (!allowed.has(field)) {
|
||||
if (unknownField === 'error') {
|
||||
throw new Error(`parseFilters: unknown field "${field}"`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const query = String(c.query).toUpperCase();
|
||||
const item = {
|
||||
field,
|
||||
query,
|
||||
op: mapOperators(query, operatorMap),
|
||||
value: c.value ?? null,
|
||||
};
|
||||
|
||||
const parsed = splitMultiValue(query, c.value);
|
||||
if (parsed != null) item.values = parsed;
|
||||
|
||||
if (c.empty === true) item.empty = true;
|
||||
if (c.disabled === true) item.disabled = true;
|
||||
if (c.hidden === true) item.hidden = true;
|
||||
if (c.readOnly === true) item.readOnly = true;
|
||||
if (c.logic != null) item.logic = c.logic;
|
||||
|
||||
out.push(item);
|
||||
}
|
||||
|
||||
if (strict && errors.length) {
|
||||
throw new Error(`parseFilters: ${errors.length} validation error(s)`);
|
||||
}
|
||||
|
||||
if (mode === 'flat') {
|
||||
return out.map(({ logic, ...rest }) => rest);
|
||||
}
|
||||
if (mode === 'tree') {
|
||||
return toTree(out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @param {string} query
|
||||
* @param {string|null|undefined} value
|
||||
* @returns {string[]|[string,string]|null}
|
||||
*/
|
||||
export function splitMultiValue(query, value) {
|
||||
if (value == null || value === '') return null;
|
||||
const s = String(value);
|
||||
const q = String(query).toUpperCase();
|
||||
if (q === 'IN' || q === 'NOT_IN') {
|
||||
return s.split(',').map((v) => v.trim()).filter(Boolean);
|
||||
}
|
||||
if (q === 'BETWEEN') {
|
||||
const parts = s.split(',').map((v) => v.trim());
|
||||
return parts.length >= 2 ? [parts[0], parts[1]] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @param {Array<object>} flat
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function toTree(flat) {
|
||||
if (!flat.length) return [];
|
||||
if (flat.length === 1) return [flat[0]];
|
||||
|
||||
let acc = flat[0];
|
||||
for (let i = 1; i < flat.length; i++) {
|
||||
const logic = flat[i].logic || 'AND';
|
||||
acc = { logic, children: [acc, flat[i]] };
|
||||
}
|
||||
return [acc];
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_OPERATOR_MAP,
|
||||
mapOperators,
|
||||
parseFilters,
|
||||
splitMultiValue,
|
||||
toTree,
|
||||
} from '../src/index.js';
|
||||
|
||||
describe('mapOperators', () => {
|
||||
it('maps known query codes', () => {
|
||||
expect(mapOperators('EQ')).toBe('=');
|
||||
expect(mapOperators('NOT_IN')).toBe('NOT IN');
|
||||
});
|
||||
|
||||
it('allows custom overrides', () => {
|
||||
expect(mapOperators('EQ', { EQ: 'equals' })).toBe('equals');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitMultiValue', () => {
|
||||
it('splits IN values', () => {
|
||||
expect(splitMultiValue('IN', 'a,b,c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('splits BETWEEN values', () => {
|
||||
expect(splitMultiValue('BETWEEN', '2024-01-01,2024-12-31')).toEqual([
|
||||
'2024-01-01',
|
||||
'2024-12-31',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFilters', () => {
|
||||
const allowedFields = ['name', 'status', 'tags', 'created'];
|
||||
|
||||
it('requires allowedFields', () => {
|
||||
expect(() => parseFilters([], {})).toThrow(/allowedFields/);
|
||||
});
|
||||
|
||||
it('parses basic conditions with logic pass-through', () => {
|
||||
const input = [
|
||||
{ field: 'name', value: 'xx', query: 'EQ' },
|
||||
{ field: 'tags', value: 'a,b', query: 'IN', logic: 'OR' },
|
||||
];
|
||||
const out = parseFilters(input, { allowedFields });
|
||||
expect(out).toEqual([
|
||||
{
|
||||
field: 'name',
|
||||
query: 'EQ',
|
||||
op: '=',
|
||||
value: 'xx',
|
||||
},
|
||||
{
|
||||
field: 'tags',
|
||||
query: 'IN',
|
||||
op: 'IN',
|
||||
value: 'a,b',
|
||||
values: ['a', 'b'],
|
||||
logic: 'OR',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves empty markers by default', () => {
|
||||
const out = parseFilters(
|
||||
[{ field: 'name', value: null, query: 'EQ', empty: true }],
|
||||
{ allowedFields },
|
||||
);
|
||||
expect(out[0].empty).toBe(true);
|
||||
});
|
||||
|
||||
it('skips disabled and hidden by default', () => {
|
||||
const out = parseFilters(
|
||||
[
|
||||
{ field: 'name', value: 'a', query: 'EQ', disabled: true },
|
||||
{ field: 'status', value: 'b', query: 'EQ', hidden: true },
|
||||
{ field: 'tags', value: 'c', query: 'EQ' },
|
||||
],
|
||||
{ allowedFields },
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].field).toBe('tags');
|
||||
});
|
||||
|
||||
it('can skip empty items when skipEmpty is true', () => {
|
||||
const out = parseFilters(
|
||||
[{ field: 'name', value: null, query: 'EQ', empty: true }],
|
||||
{ allowedFields, skipEmpty: true },
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws on unknown field by default', () => {
|
||||
expect(() =>
|
||||
parseFilters([{ field: 'unknown', value: 'x', query: 'EQ' }], {
|
||||
allowedFields,
|
||||
}),
|
||||
).toThrow(/unknown field/);
|
||||
});
|
||||
|
||||
it('supports flat mode', () => {
|
||||
const out = parseFilters(
|
||||
[{ field: 'name', value: 'x', query: 'EQ', logic: 'AND' }],
|
||||
{ allowedFields, mode: 'flat' },
|
||||
);
|
||||
expect(out[0].logic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toTree', () => {
|
||||
it('nests flat filters', () => {
|
||||
const flat = [
|
||||
{ field: 'a', query: 'EQ', op: '=', value: '1' },
|
||||
{ field: 'b', query: 'EQ', op: '=', value: '2', logic: 'OR' },
|
||||
];
|
||||
const tree = toTree(flat);
|
||||
expect(tree[0]).toMatchObject({
|
||||
logic: 'OR',
|
||||
children: expect.any(Array),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_OPERATOR_MAP', () => {
|
||||
it('includes IS_NULL operators', () => {
|
||||
expect(DEFAULT_OPERATOR_MAP.IS_NULL).toBe('IS NULL');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user