@qfei-design/make-app-sort
v0.1.0
Published
Headless Make record-sort model, React drag-sort panel, and optional host component adapters.
Keywords
Readme
@qfei-design/make-app-sort
Make App 列表排序的可复用 npm 包。它提供无框架排序模型、React 草稿控制器、 基于 dnd-kit 的多字段拖拽面板、Ant Design 官方适配器和内部样式。
设计结论
包负责什么
- 从运行时字段中只选择
capabilities.sortable === true的字段。 - 使用有序的
{ fieldKey, order }[]表示优先级,第一项优先级最高。 - 校验字段必选、字段唯一、方向只能为
asc | desc,并且最多 5 级排序。 - 管理“已应用值之外”的面板草稿、添加、删除、清空、排序方向和拖拽重排。
onConfirm异步持久化期间锁定面板;失败时保留草稿和错误。- 持久化成功且对象未切换时才同步调用
onApplied,避免旧对象结果污染新对象。 - 通过
openWithField(fieldKey, order?)连接表头排序入口和同一份面板草稿。 - 提供可注入组件合同,以及
createAntdRecordSortComponents官方适配器。
宿主负责什么
- 加载并传入标准化运行时字段,不使用字段类型白名单代替能力判断。
- 持有已经应用的排序值和当前对象的
resetKey。 - 持有工具栏触发器以及 Popover、Modal 或 Drawer 等外层容器。
- 在
onConfirm中只保存 Entity Preset 的sort。 - 在同步
onApplied中只更新已应用值。 - 以
entityKey + appliedSort为请求键加载 records,并处理取消、乱序和失败。 - 把同一份已应用排序传给 records 接口,不在前端对当前页数据做本地排序。
- 持有 CanvasTable 表头菜单,并调用控制器的
openWithField。 - 负责 Service 调用、边界日志、错误脱敏和用户错误文案转换。
包不自动探测宿主使用哪套 UI 框架。自动探测会让依赖、样式和 portal 行为不可控。
Ant Design 项目使用官方适配器作为默认方案;其他设计系统实现
RecordSortComponents。包也不直接渲染外层 Popover,因为挂载位置、层级、关闭规则
和工具栏触发器都属于宿主页面。
安装
pnpm add @qfei-design/make-app-sortReact 面板使用 dnd-kit,依赖由本包声明。React 是 peer dependency。使用 Ant Design
适配器时,宿主还需要安装 antd 和 @ant-design/icons。
Ant Design 宿主示例
import { SortAscendingOutlined } from "@ant-design/icons";
import { Button, Popover } from "antd";
import { useEffect, useMemo, useState } from "react";
import {
getSortableRecordFields,
RecordSortPanel,
useRecordSortController,
type RecordSortApplyErrorHandler,
type RecordSortField,
type RecordSortValue,
} from "@qfei-design/make-app-sort/react";
import {
createAntdRecordSortComponents,
} from "@qfei-design/make-app-sort/adapters/antd";
import "@qfei-design/make-app-sort/styles.css";
type RecordsResult = Awaited<ReturnType<typeof requestRecords>>;
type Props = {
entityKey: string;
fields: RecordSortField[];
appliedSort: RecordSortValue;
onAppliedSortChange: (value: RecordSortValue) => void;
onRecordsError: (error: unknown) => void;
onRecordsLoaded: (result: RecordsResult) => void;
onSortApplyError: RecordSortApplyErrorHandler;
};
export function SortEntry({
entityKey,
fields,
appliedSort,
onAppliedSortChange,
onRecordsError,
onRecordsLoaded,
onSortApplyError,
}: Props) {
const [open, setOpen] = useState(false);
const components = useMemo(
() => createAntdRecordSortComponents(),
[],
);
const sortableFields = useMemo(
() => getSortableRecordFields(fields),
[fields],
);
const controller = useRecordSortController({
fields,
value: appliedSort,
resetKey: entityKey,
onOpenChange: setOpen,
getErrorMessage: () => "排序保存失败,请重试",
onApplyError: onSortApplyError,
onConfirm: async (nextSort) => {
await saveEntityPreset(entityKey, { sort: nextSort });
},
onApplied: (nextSort) => {
onAppliedSortChange(nextSort);
},
});
useEffect(() => {
const requestController = new AbortController();
void requestRecords({
entityKey,
signal: requestController.signal,
sort: appliedSort,
}).then(
(result) => {
if (!requestController.signal.aborted) {
onRecordsLoaded(result);
}
},
(error: unknown) => {
if (!requestController.signal.aborted) {
onRecordsError(error);
}
},
);
return () => requestController.abort();
}, [
appliedSort,
entityKey,
onRecordsError,
onRecordsLoaded,
]);
if (sortableFields.length === 0) return null;
return (
<Popover
destroyOnHidden
open={open}
placement="bottom"
styles={{ content: { padding: 0 } }}
content={
<RecordSortPanel
components={components}
{...controller.panelProps}
/>
}
onOpenChange={(nextOpen) => {
if (nextOpen) controller.beginDraft();
else controller.discardDraft();
}}
>
<Button
aria-expanded={open}
aria-haspopup="dialog"
icon={<SortAscendingOutlined />}
>
{appliedSort.length > 0
? `${appliedSort.length} 排序`
: "排序"}
</Button>
</Popover>
);
}saveEntityPreset 和 requestRecords 是宿主接口示意,不属于本包。宿主必须让
onConfirm 只负责持久化,并在失败时 reject;控制器才会保留面板和草稿。
onApplied 只会在保存成功且 resetKey 未变化时同步调用,必须只更新受控的
appliedSort,不能返回 Promise。records 请求应由以 entityKey + appliedSort
为键的 effect 或请求库接管,并具备取消、旧请求丢弃和错误处理。
onApplyError 是必填的宿主错误边界,用于记录已持久化但应用状态或关闭动作失败的
异常,可以同步返回或返回 Promise;日志不得包含记录数据、Cookie、Authorization
或 token。
TypeScript 的 void handler 允许预先标注类型的异步函数通过赋值检查,因此包仍会在
运行时观察 onApplied 和关闭回调的真实返回值。意外返回的 Promise 会被等待,其
rejection 按 apply-failed 处理,不会形成未处理拒绝;这只是防御措施,不改变
onApplied 必须同步更新状态的宿主合同。若 onApplyError 自身失败,控制器会把原始
应用异常与上报异常合并为 AggregateError 返回,并显示安全兜底文案。应用和关闭
结果确定后,控制器会先释放 UI 提交锁,再等待异步 onApplyError;错误日志或监控
请求不会阻止用户重新打开排序草稿。
appliedSort 必须由对象 Preset 水合层按 entityKey 受控提供,不能只在组件首次
挂载时复制 initialSort。加载页面时,宿主应先完成运行时 schema 和 Preset 水合,
再挂载上述 records 请求链路。
表头联动
表头升序/降序不能直接请求 records。它只更新并打开同一个草稿:
function handleHeaderSort(fieldKey: string, order: "asc" | "desc") {
const result = controller.openWithField(fieldKey, order);
if (result.status === "field-unavailable") return;
closeHeaderMenu();
}openWithField 会更新已存在字段、填充空行或添加新行。达到五级上限时返回
limit-reached,面板仍会打开并显示本地错误;提交进行中返回 busy,不会改动
草稿。
自定义组件
非 Ant Design 项目向 RecordSortPanel 传入 RecordSortComponents:
ButtonIconButtonSelectTooltipicons:添加、升序、降序、删除、拖拽和帮助图标
组件合同只描述交互语义,不要求宿主暴露具体 UI 库实例。拖拽手柄和 dnd-kit
上下文由包维护,宿主不需要安装或编排拖拽插件。
直接使用 RecordSortPanel 时必须提供可同步或异步的 onConfirmError;使用控制器
返回的 panelProps 时,该错误边界已由控制器注入。若直接面板的错误边界自身抛错或
reject,面板会显示“排序操作失败,请重试”,不会产生未处理拒绝。
样式
在宿主入口导入一次:
import "@qfei-design/make-app-sort/styles.css";样式只覆盖 .make-app-sort 内部结构。面板默认宽度为 432px,可通过
--make-app-sort-panel-width 覆盖。外层 Popover、Modal 或 Drawer 的阴影、箭头、
z-index 和 portal 挂载点由宿主负责。
内部拖拽浮层默认 portal 到 document.body,默认 z-index 为 1100。特殊宿主可通过
getDragOverlayContainer 指定挂载容器,通过 dragOverlayZIndex 调整层级。
如果主题变量只定义在宿主局部容器上,应通过 dragOverlayClassName 给 portal
浮层附加主题类,并在该类上声明变量;SSR 环境自动使用内联回退。
稳定入口
@qfei-design/make-app-sort@qfei-design/make-app-sort/react@qfei-design/make-app-sort/adapters/antd@qfei-design/make-app-sort/styles.css
不要从 src、dist 或其他内部路径导入。完整合同见 PUBLIC_API.md。
