@svadmin/surface 0.17.0 → 0.18.1

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/README.md CHANGED
@@ -146,6 +146,29 @@ Only fences tagged `surface` (```` ```surface ````) are treated as proposals; ev
146
146
 
147
147
  `buildSurfaceAgentPrompt()` and `parseSurfaceAgentProposal()` remain compatible with the legacy proposal-only `surface-agent/v1` envelope. The new message API uses `surface-agent/v2` with a structured `cannot-fulfill` alternative. `createSurfaceAgentStream()` buffers bounded text and validates at `finish()`; it does **not** render partial JSON or implement OpenUI Lang streaming.
148
148
 
149
+ ## Host-only record selection
150
+
151
+ `SurfaceRenderer` stays read-only and executes nothing on its own. A host that wants a follow-up from a card registers `onInteraction`; only then does `resource-table` render a per-row select button:
152
+
153
+ ```svelte
154
+ <SurfaceRenderer
155
+ {spec}
156
+ {policy}
157
+ {dataProvider}
158
+ onInteraction={(event) => {
159
+ if (event.type !== 'select-record') return;
160
+ // event.record only carries JSON-safe values from the widget's declared columns.
161
+ sendFollowUp(`继续分析 ${JSON.stringify(event.record)}`);
162
+ }}
163
+ />
164
+ ```
165
+
166
+ An interaction event is presentation data, not an authorization record:
167
+
168
+ - The library never executes the follow-up, and `SurfaceSpec` gains no action, URL, event handler, or mutation field.
169
+ - `record` is a detached copy projected from the widget's declared columns; undeclared fields are dropped, and values that are not JSON-safe (for example `Date`, `NaN`, or functions) are omitted instead of converted.
170
+ - When nothing survives projection, no event is emitted. `projectInteractionRecord()` is exported for hosts and custom catalog widgets that need the same rule.
171
+
149
172
  ## Revisioned edits and explicit preview
150
173
 
151
174
  `createSurfaceRevision()` creates a validated immutable snapshot. `buildSurfaceEditMessages()` describes the current revision, catalog and allowed operations. `applySurfaceEditProposal()` validates a complete `surface-edit/v1` transaction and returns a new candidate revision without mutating the old one. Operations cover title/layout, widget/source upsert/removal and complete widget reordering by stable ID. Stale `baseRevision`, unknown IDs, invalid props, denied fields and broken final references reject the whole transaction.
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface": "0.17.x",
2
+ "surface": "0.18.x",
3
3
  "minimumSupported": {
4
4
  "@svadmin/core": "0.53.0",
5
5
  "@svadmin/ui": "0.73.0",
package/dist/catalog.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface SurfaceWidgetRendererProps {
8
8
  readonly data: SurfaceWidgetDataState;
9
9
  readonly locale?: string;
10
10
  readonly messages?: SurfaceMessages;
11
+ /** 宿主注册后,组件才渲染可选择行;组件自行按声明列投影记录。 */
12
+ readonly onSelectRecord?: (record: JsonObject) => void;
11
13
  }
12
14
  export interface SurfaceWidgetRegistration extends SurfaceWidgetDefinition {
13
15
  readonly presentation?: 'surface-appearance/v1';
@@ -14,15 +14,27 @@
14
14
  import Table from '@svadmin/ui/components/ui/table/table.svelte';
15
15
  import { styledResourceTablePropsSchema as resourceTablePropsSchema } from '../builtin-schemas.js';
16
16
  import type { SurfaceWidgetRendererProps } from '../catalog.js';
17
+ import { projectInteractionRecord } from '../interactions.js';
17
18
  import { resolveSurfaceMessages, surfaceErrorMessage } from '../localization.js';
18
19
  import { asRecordArray, displayTableValue } from '../widget-data.js';
20
+ import type { JsonObject } from '../types.js';
19
21
 
20
- let { props, data, locale = 'en-US', messages }: SurfaceWidgetRendererProps = $props();
22
+ let { props, data, locale = 'en-US', messages, onSelectRecord }: SurfaceWidgetRendererProps = $props();
21
23
  const activeMessages = $derived(resolveSurfaceMessages(locale, messages));
22
24
 
23
25
  const tableProps = $derived(Value.Decode(resourceTablePropsSchema, props));
24
26
  const tableClasses = $derived(surfaceTable());
25
27
  const records = $derived(data.status === 'ready' ? asRecordArray(data.value) : null);
28
+ const fields = $derived(tableProps.columns.map((column) => column.field));
29
+ const selectable = $derived(onSelectRecord !== undefined);
30
+ const selectLabel = $derived(activeMessages.tableSelect ?? 'Select');
31
+
32
+ // 只有宿主注册交互时才发出事件;投影后没有任何可交付字段就保持静默。
33
+ function selectRecord(record: JsonObject): void {
34
+ if (!onSelectRecord) return;
35
+ const projected = projectInteractionRecord(record, fields);
36
+ if (projected !== null) onSelectRecord(projected);
37
+ }
26
38
  </script>
27
39
 
28
40
  <Card class={tableClasses?.root}>
@@ -44,6 +56,9 @@
44
56
  {#each tableProps.columns as column (column.field)}
45
57
  <TableHead class={tableClasses?.head} scope="col">{column.label}</TableHead>
46
58
  {/each}
59
+ {#if selectable}
60
+ <TableHead class={tableClasses?.head} scope="col">{selectLabel}</TableHead>
61
+ {/if}
47
62
  </TableRow>
48
63
  </TableHeader>
49
64
  <TableBody>
@@ -56,6 +71,13 @@
56
71
  messages: activeMessages,
57
72
  })}</TableCell>
58
73
  {/each}
74
+ {#if selectable}
75
+ <TableCell class={tableClasses?.cell}>
76
+ <button type="button" class="surface-record-select" aria-label="{tableProps.title}: {selectLabel} {index + 1}" onclick={() => selectRecord(record)}>
77
+ {selectLabel}
78
+ </button>
79
+ </TableCell>
80
+ {/if}
59
81
  </TableRow>
60
82
  {/each}
61
83
  </TableBody>
@@ -68,6 +90,20 @@
68
90
  </Card>
69
91
 
70
92
  <style>
93
+ .surface-record-select {
94
+ padding: 0.125rem 0.5rem;
95
+ border: 1px solid var(--border);
96
+ border-radius: 0.375rem;
97
+ background: var(--background);
98
+ color: var(--foreground);
99
+ font: inherit;
100
+ font-size: 0.75rem;
101
+ cursor: pointer;
102
+ }
103
+
104
+ .surface-record-select:hover { background: var(--accent); }
105
+ .surface-record-select:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
106
+
71
107
  .table-state {
72
108
  display: grid;
73
109
  min-height: var(--svadmin-table-state-height, 8rem);
@@ -5,6 +5,7 @@
5
5
  import { SvelteMap } from 'svelte/reactivity';
6
6
  import { resolveSurfaceSourceData } from '../binding.js';
7
7
  import { defaultSurfaceCatalog } from '../catalog.js';
8
+ import type { SurfaceInteractionEvent } from '../interactions.js';
8
9
  import type { SurfaceRenderCatalog } from '../catalog.js';
9
10
  import { loadSurfaceSource } from '../runtime.js';
10
11
  import { resolveSurfaceMessages } from '../localization.js';
@@ -44,11 +45,13 @@
44
45
  readonly messages?: Partial<SurfaceMessages>;
45
46
  readonly class?: string;
46
47
  readonly onError?: (error: SurfaceRendererError) => void;
48
+ /** 宿主注册后,只读组件才发出选择事件;库本身不执行任何后续操作。 */
49
+ readonly onInteraction?: (event: SurfaceInteractionEvent) => void;
47
50
  }
48
51
 
49
52
  let {
50
53
  spec, policy, catalog = defaultSurfaceCatalog, dataProvider, scopeKey = '', dataScopeKey,
51
- locale, messages, class: className = '', onError,
54
+ locale, messages, class: className = '', onError, onInteraction,
52
55
  }: SurfaceRendererProps = $props();
53
56
 
54
57
  const adminContext = captureAdminContext();
@@ -68,6 +71,14 @@
68
71
  : { ...data, error: { ...data.error, message } };
69
72
  }
70
73
  const validation = $derived(validateSurfaceSpec(spec, catalog, policy));
74
+
75
+ function recordSelected(surfaceId: string, widgetId: string, sourceId: string | undefined, record: JsonObject): void {
76
+ if (!onInteraction) return;
77
+ onInteraction({
78
+ type: 'select-record', surfaceId, widgetId, record,
79
+ ...(sourceId === undefined ? {} : { sourceId }),
80
+ });
81
+ }
71
82
  const widgetRegistrations = $derived(new Map(catalog.widgets.map((widget) => [widget.type, widget])));
72
83
  const sourceStates = new SvelteMap<string, SurfaceSourceDataState>();
73
84
  let destroyed = false;
@@ -183,7 +194,10 @@
183
194
  {#if WidgetComponent}
184
195
  <WidgetComponent widgetId={widget.id} props={semantic ? withoutSurfaceAppearance(widget.props) : widget.props}
185
196
  data={presentSurfaceData(resolveSurfaceSourceData(widget, widget.binding ? sourceStates.get(widget.binding.sourceId) : undefined))}
186
- locale={activeLocale} messages={activeMessages} />
197
+ locale={activeLocale} messages={activeMessages}
198
+ {...(onInteraction === undefined ? {} : {
199
+ onSelectRecord: (record: JsonObject) => recordSelected(validation.value.surfaceId, widget.id, widget.binding?.sourceId, record),
200
+ })} />
187
201
  {/if}
188
202
  </div>
189
203
  {/each}
@@ -1,3 +1,4 @@
1
+ import type { SurfaceInteractionEvent } from '../interactions.js';
1
2
  import type { SurfaceRenderCatalog } from '../catalog.js';
2
3
  import type { SurfaceMessages } from '../localization.js';
3
4
  import type { SurfaceDataError, SurfaceDataProvider, SurfacePolicy, SurfaceValidationIssue } from '../types.js';
@@ -22,6 +23,8 @@ export interface SurfaceRendererProps {
22
23
  readonly messages?: Partial<SurfaceMessages>;
23
24
  readonly class?: string;
24
25
  readonly onError?: (error: SurfaceRendererError) => void;
26
+ /** 宿主注册后,只读组件才发出选择事件;库本身不执行任何后续操作。 */
27
+ readonly onInteraction?: (event: SurfaceInteractionEvent) => void;
25
28
  }
26
29
  declare const SurfaceRenderer: import("svelte").Component<SurfaceRendererProps, {
27
30
  refresh: (sourceId?: string) => Promise<void>;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { SURFACE_LIMITS, SURFACE_SCHEMA_VERSION } from './types.js';
2
2
  export type { JsonObject, JsonPrimitive, JsonValue, ResourceListDataSource, ResourceListSource, ResourceOneDataSource, ResourceOneSource, SurfaceBinding, SurfaceCatalog, SurfaceCatalogDataKind, SurfaceDataError, SurfaceDataProvider, SurfaceDataSource, SurfaceFilter, SurfaceGridLayout, SurfaceGridSpan, SurfacePolicy, SurfaceResourcePolicy, SurfaceSort, SurfaceSpec, SurfaceValidationCode, SurfaceValidationIssue, SurfaceValidationResult, SurfaceWidget, SurfaceWidgetDataState, SurfaceWidgetDefinition, } from './types.js';
3
3
  export { validateSurfaceSpec } from './validation.js';
4
+ export { projectInteractionRecord } from './interactions.js';
5
+ export type { SurfaceInteraction, SurfaceInteractionEvent } from './interactions.js';
4
6
  export type { SurfaceMessages } from './localization.js';
5
7
  export { SURFACE_AGENT_SCHEMA_VERSION, buildSurfaceAgentPrompt, buildSurfaceAgentMessages, parseSurfaceAgentProposal, parseSurfaceAgentResponse, createSurfaceAgentStream, } from './agent.js';
6
8
  export { SURFACE_AGENT_FENCE_TAG, splitSurfaceAgentText, } from './agent-text.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { SURFACE_LIMITS, SURFACE_SCHEMA_VERSION } from './types.js';
2
2
  export { validateSurfaceSpec } from './validation.js';
3
+ export { projectInteractionRecord } from './interactions.js';
3
4
  export { SURFACE_AGENT_SCHEMA_VERSION, buildSurfaceAgentPrompt, buildSurfaceAgentMessages, parseSurfaceAgentProposal, parseSurfaceAgentResponse, createSurfaceAgentStream, } from './agent.js';
4
5
  export { SURFACE_AGENT_FENCE_TAG, splitSurfaceAgentText, } from './agent-text.js';
5
6
  export { SURFACE_AGENT_LIMITS, SURFACE_AGENT_RESPONSE_SCHEMA_VERSION, SURFACE_CATALOG_SCHEMA_VERSION, createSurfaceCatalogManifest, createSurfaceAgentResponseSchema, selectSurfaceCatalog, } from './agent-contract.js';
@@ -0,0 +1,17 @@
1
+ import type { JsonObject } from './types.js';
2
+ /**
3
+ * 用户在界面上主动触发的只读选择,不是 action、mutation 或授权凭据。
4
+ * 宿主自行决定是否把它变成后续操作(例如追加一条用户消息)。
5
+ */
6
+ export interface SurfaceInteraction {
7
+ readonly type: 'select-record';
8
+ /** 只包含声明列的 JSON 安全值;不完整,也不能当作授权记录。 */
9
+ readonly record: JsonObject;
10
+ }
11
+ /** 渲染器补齐作用域后交给宿主的事件;宿主不得把这些字段写回渲染器。 */
12
+ export interface SurfaceInteractionEvent extends SurfaceInteraction {
13
+ readonly surfaceId: string;
14
+ readonly widgetId: string;
15
+ readonly sourceId?: string;
16
+ }
17
+ export declare function projectInteractionRecord(record: JsonObject, fields: readonly string[]): JsonObject | null;
@@ -0,0 +1,44 @@
1
+ import { isJsonValue, jsonValueIssue } from './json.js';
2
+ /**
3
+ * 只投影声明过的列,并跳过无法安全序列化的值(如 Date、函数、循环引用)。
4
+ * 嵌套对象和数组会深拷贝,宿主后续修改不会写回渲染器缓存。
5
+ * 返回 `null` 表示没有任何可交付字段,调用方不应发出事件。
6
+ *
7
+ * @param record 数据提供器返回的记录。
8
+ * @param fields 组件声明并已通过 policy 校验的字段。
9
+ * @returns 新的 JSON 对象,或 `null`。
10
+ */
11
+ const dangerousKeys = new Set(['__proto__', 'constructor', 'prototype']);
12
+ function detachJsonValue(value) {
13
+ if (typeof value !== 'object' || value === null)
14
+ return value;
15
+ if (Array.isArray(value))
16
+ return value.map((item) => detachJsonValue(item));
17
+ const copy = {};
18
+ for (const key of Object.keys(value)) {
19
+ if (dangerousKeys.has(key))
20
+ continue;
21
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
22
+ if (!descriptor || !('value' in descriptor))
23
+ continue;
24
+ const nested = descriptor.value;
25
+ if (!isJsonValue(nested))
26
+ continue;
27
+ copy[key] = detachJsonValue(nested);
28
+ }
29
+ return copy;
30
+ }
31
+ export function projectInteractionRecord(record, fields) {
32
+ const projected = {};
33
+ let included = 0;
34
+ for (const field of fields) {
35
+ if (dangerousKeys.has(field) || !Object.hasOwn(record, field))
36
+ continue;
37
+ const value = record[field];
38
+ if (value === undefined || jsonValueIssue(value) !== null)
39
+ continue;
40
+ projected[field] = detachJsonValue(value);
41
+ included += 1;
42
+ }
43
+ return included === 0 ? null : projected;
44
+ }
@@ -15,6 +15,7 @@ export interface SurfaceMessages {
15
15
  readonly tableInvalidData: string;
16
16
  readonly tableUnavailable: string;
17
17
  readonly tableEngineUnavailable: string;
18
+ readonly tableSelect?: string;
18
19
  readonly detailLoading?: string;
19
20
  readonly detailEmpty?: string;
20
21
  readonly detailUnavailable?: string;
@@ -14,6 +14,7 @@ const englishMessages = {
14
14
  tableInvalidData: 'Table data is invalid',
15
15
  tableUnavailable: 'Table data is unavailable',
16
16
  tableEngineUnavailable: 'The host has not configured the SVAR engine',
17
+ tableSelect: 'Select',
17
18
  detailLoading: 'Loading record',
18
19
  detailEmpty: 'No record',
19
20
  detailUnavailable: 'Record is unavailable',
@@ -43,6 +44,7 @@ const simplifiedChineseMessages = {
43
44
  tableInvalidData: '表格数据无效',
44
45
  tableUnavailable: '表格数据不可用',
45
46
  tableEngineUnavailable: '宿主尚未配置 SVAR 引擎',
47
+ tableSelect: '选择',
46
48
  detailLoading: '正在加载记录',
47
49
  detailEmpty: '暂无记录',
48
50
  detailUnavailable: '记录不可用',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/surface",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "Validated declarative surfaces for svadmin dashboards",
5
5
  "type": "module",
6
6
  "sideEffects": [