@lynx-go/ui-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lynx-go/ui-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Headless core for data-dense console UIs — Query / Command / Subscription over TanStack engines",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,19 +28,18 @@
28
28
  "./react": {
29
29
  "types": "./src/react/index.ts",
30
30
  "default": "./src/react/index.ts"
31
+ },
32
+ "./testing": {
33
+ "types": "./src/testing/index.ts",
34
+ "default": "./src/testing/index.ts"
31
35
  }
32
36
  },
33
37
  "files": [
34
38
  "src",
35
39
  "dist"
36
40
  ],
37
- "scripts": {
38
- "typecheck": "tsc -p tsconfig.json --noEmit",
39
- "test": "vitest run",
40
- "build": "tsup"
41
- },
42
41
  "dependencies": {
43
- "effect": "catalog:default"
42
+ "effect": "~4.0.6"
44
43
  },
45
44
  "peerDependencies": {
46
45
  "react": ">=19",
@@ -57,12 +56,21 @@
57
56
  }
58
57
  },
59
58
  "devDependencies": {
60
- "@tanstack/react-query": "catalog:default",
61
- "@tanstack/react-router": "catalog:default",
62
- "@types/react": "catalog:default",
63
- "react": "catalog:default",
64
- "typescript": "catalog:default",
65
- "vitest": "catalog:default",
66
- "@tanstack/react-form": "catalog:default"
59
+ "@tanstack/react-query": "^5.104.5",
60
+ "@tanstack/react-router": "^1.170.46",
61
+ "@types/react": "^19.3.0",
62
+ "react": "^19.3.1",
63
+ "typescript": "~5.9.3",
64
+ "vitest": "^5.0.5",
65
+ "@tanstack/react-form": "^1.33.5",
66
+ "jsdom": "^28.0.0",
67
+ "@testing-library/react": "^16.3.0",
68
+ "react-dom": "^19.3.1",
69
+ "@types/react-dom": "^19.3.0"
70
+ },
71
+ "scripts": {
72
+ "typecheck": "tsc -p tsconfig.json --noEmit",
73
+ "test": "vitest run",
74
+ "build": "tsup"
67
75
  }
68
- }
76
+ }
@@ -60,7 +60,7 @@ export function runApi<A>(eff: Effect.Effect<A, AdapterFailure>): Promise<A> {
60
60
  return;
61
61
  }
62
62
  // defect(程序错误)——归一为可观测的包装,不混入 ApiError 封闭集
63
- reject(new Error("fleet-ui: unexpected defect in adapter pipeline", { cause: err }));
63
+ reject(new Error("@lynx-go/ui-kit: unexpected defect in adapter pipeline", { cause: err }));
64
64
  }),
65
65
  );
66
66
  }
@@ -135,7 +135,7 @@ export function createHttp(options: HttpOptions = {}) {
135
135
  catch: () =>
136
136
  new HttpFailure({
137
137
  status: response.status,
138
- body: { message: `fleet-ui: response is not JSON (${path})` },
138
+ body: { message: `@lynx-go/ui-kit: response is not JSON (${path})` },
139
139
  }) as AdapterFailure,
140
140
  });
141
141
  }),
@@ -53,7 +53,7 @@ async function fetchOrApiError(
53
53
 
54
54
  class TransportInterrupt extends Error {
55
55
  constructor(detail: string) {
56
- super(`fleet-ui: transport interrupted (${detail})`);
56
+ super(`@lynx-go/ui-kit: transport interrupted (${detail})`);
57
57
  }
58
58
  }
59
59
 
@@ -61,10 +61,10 @@ export function batchKey<T, ID, C extends CommandsRecord<ID>, S extends SubsReco
61
61
 
62
62
  // ============ 失效匹配(scope 交运算在此) ============
63
63
 
64
- const FLEET_KINDS = new Set(["collection", "record", "batch"]);
64
+ const QUERY_KINDS = new Set(["collection", "record", "batch"]);
65
65
 
66
- export function isFleetKey(key: readonly unknown[]): boolean {
67
- return typeof key[0] === "string" && FLEET_KINDS.has(key[0]);
66
+ export function isKindedQueryKey(key: readonly unknown[]): boolean {
67
+ return typeof key[0] === "string" && QUERY_KINDS.has(key[0]);
68
68
  }
69
69
 
70
70
  /**
@@ -94,6 +94,6 @@ export function invalidationPredicate(
94
94
  commandScope: ResourceScope,
95
95
  ): (query: { queryKey: readonly unknown[] }) => boolean {
96
96
  return (query) =>
97
- isFleetKey(query.queryKey) &&
97
+ isKindedQueryKey(query.queryKey) &&
98
98
  selectors.some((s) => selectorMatches(s, query.queryKey, commandScope));
99
99
  }
@@ -23,6 +23,13 @@ export interface CommandDescriptor<Input, Payload, ID = never> {
23
23
  readonly __id?: ID;
24
24
  /** 跨资源失效声明(纯数据);默认与命令执行的 ctx scope 相交 */
25
25
  invalidates?: ReadonlyArray<QuerySelector>;
26
+ /**
27
+ * 存量键失效桥(迁移期):ok 后按返回的前缀键失效(TanStack 前缀语义)。
28
+ * 与 invalidates(kit 键声明)互补——存量 react-query 键(如 ["groups",
29
+ * projectId])无 fleet 键形,selector 匹配结构性不覆盖,声明面在此。
30
+ * defineResource 把 spec.legacyKeys 注入为各命令缺省;descriptor 自带优先。
31
+ */
32
+ legacyKeys?: (scope: ResourceScope) => ReadonlyArray<readonly unknown[]>;
26
33
  /** 结果覆写钩子:命令作者裁决协议层状态的领域语义(delete 已删 → 幂等 ok 等) */
27
34
  treatAs?: Partial<Record<"not-found" | "forbidden", CommandResult<Payload>>>;
28
35
  /**
@@ -40,9 +47,13 @@ export interface SubscriptionDescriptor<Params, Frame> {
40
47
 
41
48
  export type CommandsRecord<ID> = Record<string, CommandDescriptor<any, any, ID>>;
42
49
 
43
- /** defineResource 注入 __resource 后的命令映射(逐键保留具体 Input/Payload 类型) */
50
+ /** defineResource 注入 __resource/legacyKeys 后的命令映射(逐键保留具体 Input/Payload 类型) */
44
51
  export type AttachedCommands<C> = {
45
- [K in keyof C]: C[K] & { __resource?: ResourceName };
52
+ [K in keyof C]: C[K] & {
53
+ __resource?: ResourceName;
54
+ /** defineResource 从 spec.legacyKeys 注入(descriptor 自带优先);类型面恒可选 */
55
+ legacyKeys?: (scope: ResourceScope) => ReadonlyArray<readonly unknown[]>;
56
+ };
46
57
  };
47
58
  export type SubsRecord = Record<string, SubscriptionDescriptor<any, any>>;
48
59
 
@@ -57,6 +68,11 @@ export interface ResourceSpec<
57
68
  getId?: (row: T) => ID;
58
69
  /** scope 键名(如 ["projectId"]),scoped() 约定用 */
59
70
  scope?: ReadonlyArray<string>;
71
+ /**
72
+ * 存量键失效桥(迁移期):该资源命令 ok 后额外失效的 react-query 前缀键。
73
+ * 闭包收命令 ctx scope——消费方可把 scope 值嵌进键(如 ["groups", scope.projectId])。
74
+ */
75
+ legacyKeys?: (scope: ResourceScope) => ReadonlyArray<readonly unknown[]>;
60
76
 
61
77
  collection?: (
62
78
  q: CollectionQuery,
@@ -83,6 +99,7 @@ export interface Resource<
83
99
  readonly name: ResourceName;
84
100
  readonly getId?: (row: T) => ID;
85
101
  readonly scope?: ReadonlyArray<string>;
102
+ readonly legacyKeys?: (scope: ResourceScope) => ReadonlyArray<readonly unknown[]>;
86
103
 
87
104
  readonly collection?: (
88
105
  q: CollectionQuery,
@@ -105,7 +122,24 @@ export interface Resource<
105
122
  /**
106
123
  * 定义一个实体/流族的交互面(三原语各自可选)。
107
124
  * 泛型 C/S 逐键保留具体 CommandDescriptor/SubscriptionDescriptor——不被擦成 any。
125
+ *
126
+ * 重载注:commands 在 spec 上出现时返回类型收窄为必填(消消费方 `commands!`——
127
+ * 演示/demo 的 `commands?.` 习惯保留在无 commands 的形态)。
108
128
  */
129
+ export function defineResource<
130
+ T,
131
+ ID,
132
+ C extends CommandsRecord<ID> = Record<string, never>,
133
+ S extends SubsRecord = Record<string, never>,
134
+ >(
135
+ spec: ResourceSpec<T, ID, C, S> & { commands: C },
136
+ ): Resource<T, ID, C, S> & { readonly commands: AttachedCommands<C> };
137
+ export function defineResource<
138
+ T,
139
+ ID,
140
+ C extends CommandsRecord<ID> = Record<string, never>,
141
+ S extends SubsRecord = Record<string, never>,
142
+ >(spec: ResourceSpec<T, ID, C, S>): Resource<T, ID, C, S>;
109
143
  export function defineResource<
110
144
  T,
111
145
  ID,
@@ -114,14 +148,19 @@ export function defineResource<
114
148
  >(spec: ResourceSpec<T, ID, C, S>): Resource<T, ID, C, S> {
115
149
  if (!/^[a-z][a-z0-9-]*$/.test(spec.name)) {
116
150
  throw new Error(
117
- `fleet-ui: resource name "${spec.name}" must be kebab-case (it lives in query keys and URL diagnostics)`,
151
+ `@lynx-go/ui-kit: resource name "${spec.name}" must be kebab-case (it lives in query keys and URL diagnostics)`,
118
152
  );
119
153
  }
120
154
  const commands = spec.commands
121
155
  ? (Object.fromEntries(
122
156
  Object.entries(spec.commands).map(([verb, descriptor]) => [
123
157
  verb,
124
- { ...descriptor, __resource: spec.name as ResourceName },
158
+ {
159
+ ...descriptor,
160
+ __resource: spec.name as ResourceName,
161
+ // 存量键桥缺省注入:资源级声明对全部命令生效,descriptor 自带优先
162
+ legacyKeys: descriptor.legacyKeys ?? spec.legacyKeys,
163
+ },
125
164
  ]),
126
165
  ) as AttachedCommands<C>)
127
166
  : undefined;
@@ -16,6 +16,7 @@ import {
16
16
  } from "../kernel";
17
17
  import type { FlatPatch, QuerySearchSchema } from "../schema";
18
18
  import { batchQueryOptions, collectionQueryOptions, recordQueryOptions } from "./options";
19
+ import { scopeReady, useContextScope } from "./scope";
19
20
 
20
21
  /**
21
22
  * useCollection —— collection Query ↔ URL ↔ Table 的绑定。
@@ -88,9 +89,15 @@ export function useCollection<T, ID, C extends CommandsRecord<ID>, S extends Sub
88
89
  replace?: boolean;
89
90
  }) => Promise<void>;
90
91
 
91
- const target = options.scope ? scoped(resource, options.scope) : resource;
92
+ // scope 缺省链:显式传入 > ResourceScopeProvider context > 无 scope。
93
+ const contextScope = useContextScope();
94
+ const scope = options.scope ?? contextScope;
95
+ const target = scope ? scoped(resource, scope) : resource;
92
96
  const query = options.search.decode(rawSearch);
93
97
  const keepPrevious = options.query?.keepPrevious !== false;
98
+ // 未就绪自动停用(fail-safe):资源声明的 scope 键缺值时不启用——
99
+ // 消费方不再需要逐页 enabled 守卫,也堵住"未就绪空段"污染无 scope 键。
100
+ const enabled = scopeReady(resource, scope) ? options.query?.enabled : false;
94
101
 
95
102
  const result = useQuery<Segment<T>, ApiError>({
96
103
  ...collectionQueryOptions(target, query, {
@@ -98,7 +105,7 @@ export function useCollection<T, ID, C extends CommandsRecord<ID>, S extends Sub
98
105
  ...(options.query?.refetchInterval !== undefined
99
106
  ? { refetchInterval: options.query.refetchInterval }
100
107
  : {}),
101
- ...(options.query?.enabled !== undefined ? { enabled: options.query.enabled } : {}),
108
+ ...(enabled !== undefined ? { enabled } : {}),
102
109
  }),
103
110
  ...(keepPrevious ? { placeholderData: keepPreviousData } : {}),
104
111
  });
@@ -188,11 +195,13 @@ export function useRecord<T, ID, C extends CommandsRecord<ID>, S extends SubsRec
188
195
  resource: Resource<T, ID, C, S>,
189
196
  options: { id: ID | undefined; scope?: ResourceScope; query?: { staleTime?: number } },
190
197
  ): RecordHandle<T> {
191
- const target = options.scope ? scoped(resource, options.scope) : resource;
198
+ const scope = options.scope ?? useContextScope();
199
+ const target = scope ? scoped(resource, scope) : resource;
192
200
  const result = useQuery<T, ApiError>({
193
201
  ...recordQueryOptions(target, options.id, {
194
202
  ...(options.query?.staleTime !== undefined ? { staleTime: options.query.staleTime } : {}),
195
203
  }),
204
+ ...(scopeReady(resource, scope) ? {} : { enabled: false }),
196
205
  });
197
206
  return {
198
207
  data: result.data,
@@ -213,11 +222,13 @@ export function useBatch<T, ID, C extends CommandsRecord<ID>, S extends SubsReco
213
222
  ids: ReadonlyArray<ID>,
214
223
  options: { scope?: ResourceScope; query?: { staleTime?: number } } = {},
215
224
  ): BatchHandle<T, ID> {
216
- const target = options.scope ? scoped(resource, options.scope) : resource;
225
+ const scope = options.scope ?? useContextScope();
226
+ const target = scope ? scoped(resource, scope) : resource;
217
227
  const result = useQuery<ReadonlyArray<T>, ApiError>({
218
228
  ...batchQueryOptions(target, ids, {
219
229
  ...(options.query?.staleTime !== undefined ? { staleTime: options.query.staleTime } : {}),
220
230
  }),
231
+ ...(scopeReady(resource, scope) ? {} : { enabled: false }),
221
232
  });
222
233
  const rows = result.data ?? EMPTY_ROWS;
223
234
  const byId = new Map(
@@ -1,5 +1,6 @@
1
1
  import { useForm } from "@tanstack/react-form";
2
2
  import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import type { QueryClient } from "@tanstack/react-query";
3
4
  import {
4
5
  type ApiError,
5
6
  type CommandDescriptor,
@@ -9,6 +10,7 @@ import {
9
10
  type ResourceScope,
10
11
  runCommand,
11
12
  } from "../kernel";
13
+ import { useContextScope } from "./scope";
12
14
 
13
15
  /**
14
16
  * useCommand —— Command 原语的绑定(问/令/听的"令")。
@@ -16,7 +18,9 @@ import {
16
18
  * - rejected/conflict 是**结果**(mutation.data),只有失败(网络/401/解码)走 mutation.error;
17
19
  * - ok 后执行失效:descriptor.invalidates 声明优先,缺省 = 同资源全部查询(内置动词默认失效);
18
20
  * selector 默认与命令 ctx scope 相交(project A 的命令不清 project B 的缓存);
21
+ * legacyKeys 桥在此并行执行(存量 react-query 前缀键,迁移期声明面);
19
22
  * - treatAs 裁决在 kernel runCommand 单源完成;
23
+ * - scope 省略时取 ResourceScopeProvider 的 context 值;
20
24
  * - 库不内置 toast:onSettled 回调交消费方(sonner 等)。
21
25
  */
22
26
  export interface UseCommandOptions<Payload> {
@@ -35,25 +39,37 @@ export interface CommandHandle<Input, Payload> {
35
39
  reset(): void;
36
40
  }
37
41
 
42
+ /** ok 后的失效编排(kit selector 匹配 + legacyKeys 前缀桥);导出供测试与命令式编排复用。 */
43
+ export function invalidateAfterCommand(
44
+ queryClient: Pick<QueryClient, "invalidateQueries">,
45
+ descriptor: CommandDescriptor<any, any, any>,
46
+ scope: ResourceScope,
47
+ ): void {
48
+ const selectors: ReadonlyArray<QuerySelector> =
49
+ descriptor.invalidates ??
50
+ (descriptor.__resource !== undefined ? [{ resource: descriptor.__resource }] : []);
51
+ if (selectors.length > 0) {
52
+ void queryClient.invalidateQueries({
53
+ predicate: invalidationPredicate(selectors, scope),
54
+ });
55
+ }
56
+ for (const key of descriptor.legacyKeys?.(scope) ?? []) {
57
+ void queryClient.invalidateQueries({ queryKey: key });
58
+ }
59
+ }
60
+
38
61
  export function useCommand<Input, Payload, ID = never>(
39
62
  descriptor: CommandDescriptor<Input, Payload, ID>,
40
63
  options: UseCommandOptions<Payload> = {},
41
64
  ): CommandHandle<Input, Payload> {
42
65
  const queryClient = useQueryClient();
43
- const scope = options.scope ?? {};
66
+ const scope = options.scope ?? useContextScope() ?? {};
44
67
 
45
68
  const mutation = useMutation<CommandResult<Payload>, ApiError, Input>({
46
69
  mutationFn: (input) => runCommand(descriptor, input, scope),
47
70
  onSuccess: (result) => {
48
71
  if (result.status === "ok") {
49
- const selectors: ReadonlyArray<QuerySelector> =
50
- descriptor.invalidates ??
51
- (descriptor.__resource !== undefined ? [{ resource: descriptor.__resource }] : []);
52
- if (selectors.length > 0) {
53
- void queryClient.invalidateQueries({
54
- predicate: invalidationPredicate(selectors, scope),
55
- });
56
- }
72
+ invalidateAfterCommand(queryClient, descriptor, scope);
57
73
  }
58
74
  options.onSettled?.(result, null);
59
75
  },
@@ -14,6 +14,7 @@ export {
14
14
  } from "./bindings";
15
15
  export {
16
16
  type CommandHandle,
17
+ invalidateAfterCommand,
17
18
  type UseCommandFormOptions,
18
19
  type UseCommandOptions,
19
20
  useCommand,
@@ -43,4 +44,9 @@ export {
43
44
  type PreferenceCodec,
44
45
  usePreference,
45
46
  } from "./preferences";
47
+ export {
48
+ ResourceScopeProvider,
49
+ scopeReady,
50
+ useContextScope,
51
+ } from "./scope";
46
52
  export { type UseSubscriptionOptions, useSubscription } from "./subscription";
@@ -36,7 +36,7 @@ export function collectionQueryOptions<T, ID, C extends CommandsRecord<ID>, S ex
36
36
  const collection = resource.collection;
37
37
  if (!collection) {
38
38
  throw new Error(
39
- `fleet-ui: resource "${String(resource.name)}" has no collection query — did you mean to wrap it with clientCollection()?`,
39
+ `@lynx-go/ui-kit: resource "${String(resource.name)}" has no collection query — did you mean to wrap it with clientCollection()?`,
40
40
  );
41
41
  }
42
42
  return {
@@ -59,7 +59,7 @@ export function recordQueryOptions<T, ID, C extends CommandsRecord<ID>, S extend
59
59
  } {
60
60
  const record = resource.record;
61
61
  if (!record) {
62
- throw new Error(`fleet-ui: resource "${String(resource.name)}" has no record query`);
62
+ throw new Error(`@lynx-go/ui-kit: resource "${String(resource.name)}" has no record query`);
63
63
  }
64
64
  if (id === undefined)
65
65
  return {
@@ -89,7 +89,7 @@ export function batchQueryOptions<T, ID, C extends CommandsRecord<ID>, S extends
89
89
  } {
90
90
  const batch = resource.batch;
91
91
  if (!batch) {
92
- throw new Error(`fleet-ui: resource "${String(resource.name)}" has no batch query`);
92
+ throw new Error(`@lynx-go/ui-kit: resource "${String(resource.name)}" has no batch query`);
93
93
  }
94
94
  const scope = scopeOf(resource);
95
95
  return {
@@ -0,0 +1,43 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import type { ResourceScope } from "../kernel";
3
+
4
+ /**
5
+ * ResourceScopeProvider —— scope 线程的 Context 单源(scoped() 约定的绑定侧)。
6
+ *
7
+ * 绑定(useCollection/useRecord/useBatch/useCommand/useCommandForm/useSubscription)
8
+ * 在调用点省略 scope 时从这里取值;显式传入的 scope 优先(逃生舱)。
9
+ * 消费方典型形态:应用根把"当前项目"等作用域单源挂进 Provider,页面绑定
10
+ * 不再逐处传 scope 字面量。
11
+ *
12
+ * 未就绪语义:资源声明了 scope 键(spec.scope)而生效 scope 中该键为
13
+ * undefined 时,查询绑定自动停用(enabled: false)——取代消费方逐页的
14
+ * enabled 守卫;"未就绪"查询不启用,也就不会产出"无 scope 键"下的空段
15
+ * 缓存(canonicalScope 丢 undefined 的同键污染面随之关闭)。
16
+ */
17
+ const ResourceScopeContext = createContext<ResourceScope | undefined>(undefined);
18
+
19
+ export function ResourceScopeProvider({
20
+ value,
21
+ children,
22
+ }: {
23
+ value: ResourceScope;
24
+ children: ReactNode;
25
+ }) {
26
+ return <ResourceScopeContext value={value}>{children}</ResourceScopeContext>;
27
+ }
28
+
29
+ /** @internal 绑定层共用的 context 读取(省略 scope 时的缺省源)。 */
30
+ export function useContextScope(): ResourceScope | undefined {
31
+ return useContext(ResourceScopeContext);
32
+ }
33
+
34
+ /**
35
+ * 资源声明的 scope 键是否全部就绪(未声明 = 恒就绪)。
36
+ * 查询绑定用它做自动停用;显式 query.enabled 无法越过未就绪(fail-safe)。
37
+ */
38
+ export function scopeReady(
39
+ resource: { scope?: ReadonlyArray<string> },
40
+ scope: ResourceScope | undefined,
41
+ ): boolean {
42
+ return (resource.scope ?? []).every((key) => scope?.[key] !== undefined);
43
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  SubscriptionPolicy,
8
8
  Transport,
9
9
  } from "../kernel";
10
+ import { useContextScope } from "./scope";
10
11
 
11
12
  /**
12
13
  * useSubscription —— Subscription 原语的绑定(问/令/听的"听")。
@@ -73,8 +74,10 @@ export function useSubscription<Params, F>(
73
74
  const isDescriptor =
74
75
  typeof (source as { policy?: unknown }).policy === "object" &&
75
76
  typeof source.open === "function";
77
+ // scope 缺省链同查询/命令绑定:显式传入 > ResourceScopeProvider context > 空
78
+ const scope = options.scope ?? useContextScope() ?? {};
76
79
  const paramsKey = options.params === undefined ? "" : JSON.stringify(options.params);
77
- const scopeKey = JSON.stringify(options.scope ?? {});
80
+ const scopeKey = JSON.stringify(scope);
78
81
  const policy = options.policy ?? descriptor.policy;
79
82
  const policyKey = JSON.stringify(policy ?? null);
80
83
  const anchorKey = options.cursor === undefined ? "\u0000waiting" : `\u0000${options.cursor}`;
@@ -82,7 +85,7 @@ export function useSubscription<Params, F>(
82
85
  // biome-ignore lint/correctness/useExhaustiveDependencies: descriptor.open/options.* 由 paramsKey/scopeKey 记忆——params 对象标识每渲染变化不应重开流
83
86
  const transport = useMemo<Transport<F>>(() => {
84
87
  if (!isDescriptor) return source as Transport<F>;
85
- return descriptor.open(options.params as Params, options.scope ?? {});
88
+ return descriptor.open(options.params as Params, scope);
86
89
  }, [source, isDescriptor, paramsKey, scopeKey]);
87
90
 
88
91
  const [engine, setEngine] = useState<SubscriptionEngine<F> | null>(null);
@@ -50,7 +50,7 @@ export type FlatPatch = Partial<
50
50
  export interface StandardSchemaV1<Input = unknown, Output = Input> {
51
51
  "~standard": {
52
52
  version: 1;
53
- vendor: "fleet-ui";
53
+ vendor: "@lynx-go/ui-kit";
54
54
  validate(value: Input): { value: Output };
55
55
  };
56
56
  }
@@ -123,7 +123,7 @@ export function collectionQuerySchema(spec: QuerySchemaSpec = {}): QuerySearchSc
123
123
  );
124
124
  if (!isText) {
125
125
  throw new Error(
126
- "fleet-ui: predicate with OR is not URL-serializable — keep it in local state or a custom search param",
126
+ "@lynx-go/ui-kit: predicate with OR is not URL-serializable — keep it in local state or a custom search param",
127
127
  );
128
128
  }
129
129
  return leaves;
@@ -211,7 +211,7 @@ export function collectionQuerySchema(spec: QuerySchemaSpec = {}): QuerySearchSc
211
211
  return {
212
212
  "~standard": {
213
213
  version: 1,
214
- vendor: "fleet-ui",
214
+ vendor: "@lynx-go/ui-kit",
215
215
  validate(value: unknown) {
216
216
  return { value: normalize(value) };
217
217
  },
@@ -0,0 +1,59 @@
1
+ import type { FieldKey, Operator, Predicate } from "../kernel";
2
+
3
+ /**
4
+ * 谓词 ↔ 过滤值的纯函数层(FilterBar 块与消费方过滤 UI 共用的投影)。
5
+ *
6
+ * 背景:CollectionQuery 的谓词是 AND/OR 树,但过滤 UI 的真身几乎总是
7
+ * "field → 单值"(下拉/输入框)。本层把两者间的样板(叶子抽取、归一化
8
+ * 拼装)收敛为纯函数——FilterBar 变薄壳,消费方不再逐页手写 filtersOf
9
+ * 与三元归一化。
10
+ */
11
+
12
+ export interface FilterLeaf {
13
+ field: string;
14
+ op: Operator;
15
+ value: string | number | boolean | ReadonlyArray<string>;
16
+ }
17
+
18
+ /**
19
+ * 过滤叶子抽取:展开 AND 树(与 decode 产物同形)返回全部叶子。
20
+ * OR 分支(textPredicate 的 any 形)同样展开为叶子——值读取语义下
21
+ * OR 树仅由文本搜索产生;一般 OR 树不进 URL(codec 裁决),此函数
22
+ * 只作投影不做序列化,保持宽容。
23
+ */
24
+ export function filterLeaves(predicate: Predicate | null | undefined): ReadonlyArray<FilterLeaf> {
25
+ if (predicate === null || predicate === undefined) return [];
26
+ if ("field" in predicate) return [predicate];
27
+ if ("all" in predicate) return predicate.all.flatMap(filterLeaves);
28
+ return predicate.any.flatMap(filterLeaves);
29
+ }
30
+
31
+ /**
32
+ * field → 单值投影(过滤 UI 的读路径):每字段取叶子值,多叶子时后者
33
+ * 覆盖(与单选下拉语义一致)。数组值字段(in 算子)不出现在单值投影中
34
+ * ——需要时用 filterLeaves 直读。
35
+ */
36
+ export function filterValues(
37
+ predicate: Predicate | null | undefined,
38
+ ): Record<string, string | number | boolean> {
39
+ const out: Record<string, string | number | boolean> = {};
40
+ for (const leaf of filterLeaves(predicate)) {
41
+ if (typeof leaf.value !== "object") out[leaf.field as string] = leaf.value;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * 叶子清单 → 规范谓词(过滤 UI 的写路径):0 → null(清除)、
48
+ * 1 → 裸叶、n → AND。与 setPredicate 配合使用,取代手写三元归一化。
49
+ */
50
+ export function predicateOf(parts: ReadonlyArray<Predicate>): Predicate | null {
51
+ if (parts.length === 0) return null;
52
+ if (parts.length === 1) return parts[0] as Predicate;
53
+ return { all: [...parts] };
54
+ }
55
+
56
+ /** 便捷构造:等值过滤叶(过滤 UI 的最常见形态)。 */
57
+ export function eqFilter(field: FieldKey, value: string | number | boolean): Predicate {
58
+ return { field, op: "eq", value };
59
+ }
@@ -7,3 +7,10 @@ export {
7
7
  type QuerySearchSchema,
8
8
  type StandardSchemaV1,
9
9
  } from "./codec";
10
+ export {
11
+ eqFilter,
12
+ type FilterLeaf,
13
+ filterLeaves,
14
+ filterValues,
15
+ predicateOf,
16
+ } from "./filters";
@@ -0,0 +1,88 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+ import {
3
+ createMemoryHistory,
4
+ createRootRoute,
5
+ createRoute,
6
+ createRouter,
7
+ RouterProvider,
8
+ } from "@tanstack/react-router";
9
+ import { act, render, type RenderOptions } from "@testing-library/react";
10
+ import type { ReactElement } from "react";
11
+ import type { ResourceScope } from "../kernel";
12
+ import { ResourceScopeProvider } from "../react/scope";
13
+ import type { QuerySearchSchema } from "../schema";
14
+
15
+ /**
16
+ * /testing —— 消费方测试 harness(页面级渲染 + URL/Query/Scope 三上下文一体接线)。
17
+ *
18
+ * 消费方自建的样板(TestRouter + SearchProbe + per-test QueryClient + scope
19
+ * Provider 包装)在此单源:useCollection 系绑定需要全部三上下文,缺一即假绿。
20
+ * mock 面建议从"数据源"上移到"resource 动词"(defineResource 的 collection/
21
+ * commands 覆写)——本 harness 不绑 mock 方案。
22
+ */
23
+
24
+ export interface RenderWithFleetOptions extends Omit<RenderOptions, "wrapper"> {
25
+ /** 挂载 ui 的路由路径(默认 "/";支持 $param 段,值由 pathname 给出) */
26
+ path?: string;
27
+ /** 初始 search(经 searchSchema 归一——与真实 validateSearch 同路,非裸对象) */
28
+ search?: Record<string, unknown>;
29
+ /** 与被测页面共用的 schema 实例;给定时接线 validateSearch(不传则 search 原样) */
30
+ searchSchema?: QuerySearchSchema;
31
+ scope?: ResourceScope;
32
+ queryClient?: QueryClient;
33
+ }
34
+
35
+ export interface FleetRenderResult {
36
+ /** 解析后的 search 对象(断言用解析值,不读原始 query 串——TanStack 数组方言) */
37
+ getSearch(): Record<string, unknown>;
38
+ queryClient: QueryClient;
39
+ }
40
+
41
+ export async function renderWithFleet(
42
+ ui: ReactElement,
43
+ options: RenderWithFleetOptions = {},
44
+ ): Promise<FleetRenderResult> {
45
+ const queryClient =
46
+ options.queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
47
+ const path = options.path ?? "/";
48
+ const { searchSchema, search, scope, ...renderOptions } = options;
49
+ const initialSearch = search ?? {};
50
+
51
+ const validateSearch = searchSchema
52
+ ? ((input: unknown) =>
53
+ searchSchema.toSearchSchema()["~standard"].validate(input).value) as never
54
+ : undefined;
55
+
56
+ const rootRoute = createRootRoute({ validateSearch });
57
+ const pageRoute = createRoute({
58
+ getParentRoute: () => rootRoute,
59
+ path,
60
+ component: () => (
61
+ <ResourceScopeProvider value={scope ?? {}}>
62
+ <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
63
+ </ResourceScopeProvider>
64
+ ),
65
+ });
66
+ const routeTree = rootRoute.addChildren([pageRoute]);
67
+ // initialEntries 只收字符串——按 TanStack 默认 search 方言(每值 JSON 编码)
68
+ // 自拼 query 串,与真实 URL 解析同路(数组=单键 JSON、数字不落字符串)。
69
+ const qs = Object.entries(initialSearch)
70
+ .filter(([, v]) => v !== undefined)
71
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(JSON.stringify(v))}`)
72
+ .join("&");
73
+ const router = createRouter({
74
+ routeTree,
75
+ history: createMemoryHistory({
76
+ initialEntries: [path + (qs ? `?${qs}` : "")],
77
+ }),
78
+ });
79
+
80
+ const rendered = render(<RouterProvider router={router} />, renderOptions);
81
+ // RouterProvider 首提交异步——冲刷到稳定再交还断言(torchwood TestRouter 同坑)
82
+ await act(async () => {});
83
+
84
+ return {
85
+ getSearch: () => router.state.location.search as Record<string, unknown>,
86
+ queryClient,
87
+ };
88
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ type FleetRenderResult,
3
+ type RenderWithFleetOptions,
4
+ renderWithFleet,
5
+ } from "./harness";