@qfei-design/make-app-group
v0.1.2
Published
Headless Make record-group model, React drag-reorder panel, and optional host component adapters.
Downloads
406
Keywords
Readme
@qfei-design/make-app-group
Make App 列表分组配置的可复用 npm 包。它提供无框架分组模型、React 草稿控制器、 基于 dnd-kit 的三级分组拖拽面板、Ant Design 官方适配器和内部样式。
设计边界
包负责:
- 从运行时字段中只选择
capabilities.groupable === true的字段。 - 使用有序
{ fieldKey, order }[]表示分组层级,第一项是一级分组。 - 校验字段唯一、方向只能为
asc | desc,最多 3 个分组条件。 capabilities.sortable === true的字段可切换分组方向,其余字段固定升序并禁用方向按钮。- 管理已应用值之外的面板草稿、显式添加的空草稿行、删除、清空、方向切换和拖拽重排。
onConfirm异步持久化期间锁定面板;失败时保留草稿和错误。- 持久化成功且
resetKey未变化时才同步调用onApplied。 - 通过
openWithField(fieldKey, order?)连接宿主表头入口和同一份面板草稿。 - 提供可注入组件合同,以及
createAntdRecordGroupComponents官方适配器。
宿主负责:
- 加载并传入标准化运行时字段;包不自动探测字段和 UI 框架。
- 持有已应用分组值和当前对象的
resetKey。 - 持有工具栏触发器以及 Popover、Modal 或 Drawer 等外层容器。
- 在
onConfirm中只保存 Entity Preset 的group。 - 在同步
onApplied中只更新已应用分组值,不能返回 Promise。 - 以
entityKey + appliedGroup为请求键加载分组数据和叶子明细,并处理取消、乱序和失败。 - 负责 Service 调用、Canvas 表格刷新、边界日志、错误脱敏和用户错误文案转换。
包不生成 CEL,不请求 Data API,不保存 Preset,不渲染外层 Popover,也不做 Canvas 表格 渲染。外层容器的挂载点、关闭规则和工具栏触发器都属于宿主页面。
安装
pnpm add @qfei-design/make-app-groupReact 面板使用 dnd-kit,依赖由本包声明。React 是 peer dependency。使用 Ant Design
适配器时,宿主还需要安装 antd 和 @ant-design/icons。
Ant Design 宿主示例
import { GroupOutlined } from "@ant-design/icons";
import { Button, Popover } from "antd";
import { useEffect, useMemo, useState } from "react";
import {
getGroupableRecordFields,
RecordGroupPanel,
useRecordGroupController,
type RecordGroupApplyErrorHandler,
type RecordGroupField,
type RecordGroupValue,
} from "@qfei-design/make-app-group/react";
import {
createAntdRecordGroupComponents,
} from "@qfei-design/make-app-group/adapters/antd";
import "@qfei-design/make-app-group/styles.css";
type GroupResult = Awaited<ReturnType<typeof requestGroupData>>;
type Props = {
entityKey: string;
fields: RecordGroupField[];
appliedGroup: RecordGroupValue;
onAppliedGroupChange: (value: RecordGroupValue) => void;
onGroupApplyError: RecordGroupApplyErrorHandler;
onGroupDataError: (error: unknown) => void;
onGroupDataLoaded: (result: GroupResult) => void;
};
export function GroupEntry({
entityKey,
fields,
appliedGroup,
onAppliedGroupChange,
onGroupApplyError,
onGroupDataError,
onGroupDataLoaded,
}: Props) {
const [open, setOpen] = useState(false);
const components = useMemo(
() => createAntdRecordGroupComponents(),
[],
);
const groupableFields = useMemo(
() => getGroupableRecordFields(fields),
[fields],
);
const controller = useRecordGroupController({
fields,
value: appliedGroup,
resetKey: entityKey,
onOpenChange: setOpen,
getErrorMessage: () => "分组保存失败,请重试",
onApplyError: onGroupApplyError,
onConfirm: async (nextGroup) => {
await saveEntityPreset(entityKey, { group: nextGroup });
},
onApplied: (nextGroup) => {
onAppliedGroupChange(nextGroup);
},
});
useEffect(() => {
const requestController = new AbortController();
void requestGroupData({
entityKey,
group: appliedGroup,
signal: requestController.signal,
}).then(
(result) => {
if (!requestController.signal.aborted) {
onGroupDataLoaded(result);
}
},
(error: unknown) => {
if (!requestController.signal.aborted) {
onGroupDataError(error);
}
},
);
return () => requestController.abort();
}, [
appliedGroup,
entityKey,
onGroupDataError,
onGroupDataLoaded,
]);
if (groupableFields.length === 0) return null;
return (
<Popover
destroyOnHidden
open={open}
placement="bottom"
styles={{ content: { padding: 0 } }}
content={
<RecordGroupPanel
components={components}
{...controller.panelProps}
/>
}
onOpenChange={(nextOpen) => {
if (nextOpen) controller.beginDraft();
else controller.discardDraft();
}}
>
<Button
aria-expanded={open}
aria-haspopup="dialog"
icon={<GroupOutlined />}
>
{appliedGroup.length > 0
? `${appliedGroup.length} 分组`
: "分组"}
</Button>
</Popover>
);
}saveEntityPreset 和 requestGroupData 是宿主接口示意,不属于本包。宿主必须让
onConfirm 只负责持久化,并在失败时 reject;控制器才会保留面板和草稿。
onApplied 只会在保存成功且 resetKey 未变化时同步调用,必须只更新受控的
appliedGroup,不能返回 Promise。分组数据和明细请求应由以
entityKey + appliedGroup 为键的 effect 或请求库接管,并具备取消、旧请求丢弃和错误处理。
onApplyError 是必填的宿主错误边界,用于记录已持久化但应用状态或关闭动作失败的
异常,可以同步返回或返回 Promise;日志不得包含记录数据、Cookie、Authorization 或 token。
表头联动
表头分组入口不能直接请求分组数据。它只更新并打开同一个草稿:
function handleHeaderGroup(fieldKey: string, order: "asc" | "desc") {
const result = controller.openWithField(fieldKey, order);
if (result.status === "field-unavailable") return;
closeHeaderMenu();
}openWithField 会更新已存在字段、填充当前空草稿行或添加新行。达到三级上限时返回
limit-reached,面板仍会打开并显示本地错误;提交进行中返回 busy,不会改动草稿。
自定义组件
非 Ant Design 项目向 RecordGroupPanel 传入 RecordGroupComponents:
ButtonIconButtonSelectTooltipicons:添加、升序、降序、删除、拖拽和帮助图标
组件合同只描述交互语义,不要求宿主暴露具体 UI 库实例。拖拽手柄和 dnd-kit 上下文由包维护,
宿主不需要安装或编排拖拽插件。直接使用 RecordGroupPanel 时必须提供可同步或异步的
onConfirmError;使用控制器返回的 panelProps 时,该错误边界已由控制器注入。
若直接面板的错误边界自身抛错或 reject,面板会显示“分组操作失败,请重试”,不会产生未处理拒绝。
标准面板会在底部展示“添加条件组”,空分组默认展示一行草稿;达到三级上限时不再展示该按钮,
没有可选字段、已有空草稿行或保存中时,该按钮禁用。
如果通过 labels.help 传入自定义 ReactNode 帮助内容,建议同时传入 labels.helpAriaLabel;
未传入时,帮助图标使用“分组条件说明”作为中性可访问名称,避免读出与视觉提示不一致的默认规则。
样式
在宿主入口导入一次:
import "@qfei-design/make-app-group/styles.css";样式只覆盖 .make-app-group 内部结构。面板默认宽度为 432px,可通过
--make-app-group-panel-width 覆盖。外层 Popover、Modal 或 Drawer 的阴影、箭头、
z-index 和 portal 挂载点由宿主负责。
内部拖拽浮层默认 portal 到 document.body,默认 z-index 为 1100。特殊宿主可通过
getDragOverlayContainer 指定挂载容器,通过 dragOverlayZIndex 调整层级。
如果主题变量只定义在宿主局部容器上,应通过 dragOverlayClassName 给 portal
浮层附加主题类,并在该类上声明变量;SSR 环境自动使用内联回退。
稳定入口
@qfei-design/make-app-group@qfei-design/make-app-group/react@qfei-design/make-app-group/adapters/antd@qfei-design/make-app-group/styles.css
不要从 src、dist 或其他内部路径导入。完整合同见 PUBLIC_API.md。
