Supabase Studio 表编辑器:表格过滤与排序的 URL 状态持久化架构解析
Supabase Studio 表编辑器表格过滤与排序的 URL 状态持久化架构解析【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase本文以 Supabase StudioSupabase 控制台的表编辑器界面的开发者笔记apps/studio/components/grid/hooks/README.md为主体系统讲解表格过滤Filter与排序Sort功能的URL 状态持久化 草稿-应用draft-then-apply双层架构。读完后你将理解过滤/排序状态为何存储在 URL 查询参数中、URL 字符串与类型化对象之间如何互相转换、弹窗组件如何用本地草稿态隔离编辑与提交以及 Studio 如何利用 localStorage 让上次看到的视图在跨会话后仍可恢复。一、总体设计URL 作为唯一事实来源Source of TruthREADME 开篇即给出该系统的三大设计目标持久化状态过滤条件与排序规则存储在 URL 查询参数中天然支持书签收藏与链接分享——任何人拿到同一条 URL打开的表格视图完全一致关注点分离状态管理与 URL 交互的逻辑封装在自定义 Hook 中UI 组件只消费格式化后的对象与回调不感知 URL 细节草稿-应用模式Draft-then-applyUI 组件过滤/排序弹窗维护一份本地草稿状态只有用户显式点击Apply后变更才通过回调写回 URL。整体数据流在文档中概括为 6 步这里保留其原始脉络URL 参数存储原始的过滤/排序字符串Hook 读取并将其格式化为可用的类型化对象Filter[]/Sort[]UI 组件接收格式化对象与回调组件内部维护草稿状态供用户编辑用户点击 Apply 时回调将新状态写回 URL 参数由专门的保存 Hook 触发副作用如写入 localStorage、触发查询。在 Supabase Studio 中这套机制服务于表编辑器Table Editor打开/project/{ref}/editor/{tableId}页面后用户在 Filter/Sort 弹窗中配置的视图会被编码进 URL例如?sortcreated_at:descfilterage:gte:18filtername:like:foo这样只看 age 大于等于 18 且按创建时间倒序这一视图就可以被复制、分享、收藏。二、URL 参数格式与转换工具README 指出Filter and sort parameters are stored in URL using specific formats并依赖一组转换工具formatFilterURLParams、formatSortURLParams等完成 URL 字符串与类型化对象之间的翻译。这些工具集中实现在 SupabaseGrid.utils.ts下面逐一看它们的真实实现。2.1 排序参数column:asc|desc// 格式化URL 字符串数组 - Sort[] export function formatSortURLParams( table: { name: string; columns: { name: string }[] }, sort?: string[] ): Sort[] { if (Array.isArray(sort)) { return compact( sort.map((s) { const [column, order] s.split(:) // Reject any possible malformed sort param if (!column || !order) return undefined // 若列名在表中不存在则拒绝避免产生无效排序 if (table.columns.find((c) c.name column) undefined) return undefined else return { table: table.name, column, ascending: order asc } }) ) } return [] } // 序列化Sort[] - URL 字符串数组 export function sortsToUrlParams(sorts: { column: string; ascending?: boolean }[]) { return sorts.map((sort) ${sort.column}:${sort.ascending ? asc : desc}) }两个值得注意的防御性细节畸形参数直接丢弃column或order为空的参数项返回undefined再由 lodash 的compact过滤保证 URL 被人手工篡改也不会产生脏状态列名白名单校验formatSortURLParams需要传入表结构table参数列名不在table.columns中直接拒绝。这就是 README 中强调useTableSort格式化排序参数需要表名needs table name的原因——排序规则必须绑定真实存在的列。2.2 过滤参数column:abbrev:valueexport function formatFilterURLParams(filter?: string[]): Filter[] { return ( Array.isArray(filter) ? filter .map((f) { const [column, operatorAbbrev, ...value] f.split(:) // 允许值中包含冒号split 后再拼回 const formattedValue value.join(:) const operator FilterOperatorOptions.find( (option) option.abbrev operatorAbbrev ) if (!column || !operatorAbbrev || !operator) return undefined else return { column, operator: operator.value, value: formattedValue || } }) .filter((f) f ! undefined) : [] ) as Filter[] } export function filtersToUrlParams( filters: { column: string | Arraystring; operator: string; value: string }[] ) { return filters.map((filter) { const selectedOperator FilterOperatorOptions.find( (option) option.value filter.operator ) return ${filter.column}:${selectedOperator?.abbrev}:${filter.value} }) }过滤值可能包含冒号如时间戳2024:01:01因此解析时用...value剩余部分再join(:)拼回这是 URL 编码设计中的一个易错点实现中已明确处理。2.3 过滤操作符体系操作符的完整定义在 Filter.constants.ts 中每个操作符包含 SQL 值、界面文案和 URL 缩写三种形态valueSQL 操作符abbrevURL 缩写含义eqequals等于neqnot equal不等于gtgreater thanltless thangtegreater than or equallteless than or equal~~likePostgreslike操作符~~*ilikePostgresilike不区分大小写ininin 值列表isis判断 null / not null / true / false这套缩写表同时服务于序列化与反序列化filtersToUrlParams按value查abbrev写入 URLformatFilterURLParams按abbrev查回value。类型层面操作符全集定义在 pg-meta 的类型文件 中export interface Sort { table: string column: string ascending?: boolean nullsFirst?: boolean } export interface Filter { column: string | Arraystring operator: FilterOperator value: any }注意Filter.column允许是string | Arraystring支持in这类多值操作符而 UI 层的缩写表只暴露了 10 个常用操作符未包含!~~/!~~*not like——即 URL 层是一个受控子集。三、核心 HooksuseTableFilter 与 useTableSort3.1 useTableSort文档签名与当前实现README 给出的契约是// Returns: { sorts, urlSorts, onApplySorts }职责从 URL 读取原始排序参数、结合表名格式化为Sort[]、提供应用回调、持久化到 URL 并触发副作用。当前的 useTableSort.ts 在此基础上进一步演化实际返回return { sorts, urlSorts, onApplySorts, addOrUpdateSort, removeSort }关键实现有三处URL 读取通过 useTableEditorFiltersSort 拿到原始sorts即 URL 中所有sort参数再用formatSortURLParams(snap.originalTable, urlSorts)结合表结构解析为Sort[]onApplySorts写回 URL把Sort[]序列化为字符串数组后通过setParams更新查询参数注意它只写sort不碰filter避免相互覆盖列头点击的便捷操作addOrUpdateSort/removeSort新列排序插入数组头部最高优先级同列再次点击则更新其ascending方向。这与数据库多键排序按声明顺序生效的语义一致。useTableEditorFiltersSort本身基于 Next.js router 实现读写const urlParams useMemo(() { return new URLSearchParams(router.asPath.split(?)[1]) }, [router.asPath]) const setParams useCallback( (fn: (prevParams: { filter?: string[]; sort?: string[] }) { filter?: string[]; sort?: string[] }) { const prevParams { filter: filters, sort: sorts } const newParams fn(prevParams) router.push( { query: { ...router.query, ...(newParams.sort ! undefined ? { sort: newParams.sort } : {}) } }, undefined, { shallow: true } // shallow 路由只改 URL不重新触发数据请求 ) }, [filters, sorts] )shallow: true是状态进 URL 却不刷新页面数据的关键改变过滤条件后由查询层react-query自行决定重取数据而不是整页跳转。3.2 useTableFilter快照优先、URL 兜底README 记录的契约是{ filters, urlFilters, onApplyFilters }职责与 sort 对称。需要说明的是当前仓库中的 useTableFilter.ts 已演进为双模式实现文档注释写得很直白/** * When called inside a TableEditorTableStateContextProvider, reads/writes * filters via the valtio table state. When called outside (e.g. the sidebar), * falls back to reading filters from URL params. Mutations (setFilters, * clearFilters) throw if called outside the provider. */ export function useTableFilter() { const snap useOptionalTableEditorTableStateSnapshot() const { filters: urlFilters } useTableEditorFiltersSort() const urlParsedFilters formatFilterURLParams(urlFilters) const filters snap ? snap.filters : urlParsedFilters // setFilters / clearFilters: 无 snap 时 throw }也就是说在表编辑器主区域存在 valtio 表状态快照snap时filters读自快照变更通过setFilters/clearFilters写回快照再由生命周期 Hook 同步到 URL见第四节在侧边栏等 Provider 之外的场景则回退为直接解析 URL 参数保持只读可用无快照时调用变更方法会抛错这是一个显式的契约保护。这与 README 中URL parameters as the source of truth的精神一致URL 始终是可分享的持久层快照只是编辑会话内的高频状态缓存。四、Draft-then-applyFilterPopoverPrimitive 与 SortPopoverPrimitiveREADME 的Component Implementation章节描述了四个步骤本地状态管理、对草稿做增删改、点击 Apply 才通过回调提交、外部变更时与 props 同步。两个 Popover 组件是这段描述的完整落地。4.1 FilterPopoverPrimitiveFilterPopoverPrimitive.tsx 接收{ filters, onApplyFilters }两个 props内部维护localFilters草稿草稿同步useMemo(() { setLocalFilters(filters) }, [filters])——外部过滤条件如 URL 变化变化时草稿跟随 props 重置增改删只作用于草稿onAddFilter默认取表的第一列、操作符、空值、onChangeFilter按索引替换、onDeleteFilter按索引删除全部只改localFiltersApply 时提交onSelectApplyFilters先做一次清洗——对uuid格式的列trim掉值两侧空白避免 UUID 比对失败然后onApplyFilters(formattedFilters)提交Apply 按钮禁用逻辑disabled{isEqual(localFilters, filters)}草稿与外部状态完全一致时按钮置灰避免无意义的 URL 变更键盘体验在输入框按 Enter 直接触发 Apply。按钮文案也随状态切换无过滤时显示 Filter有过滤时显示 Filtered by N rule(s)。4.2 SortPopoverPrimitive草稿态 拖拽排序 大表保护SortPopoverPrimitive.tsx 在同样的草稿-应用骨架上多了三块工程细节1拖拽调整排序优先级。多键排序的列顺序即优先级组件用dnd-kit实现垂直列表拖拽PointerSensorKeyboardSensor支持键盘排序onDragEnd中通过arrayMove重排草稿数组。2外部变更与自身提交的区分同步。这是组件头注释明确提到的special sync mechanismconst lastSortsRef useRefSort[](sorts) const isApplyingRef useRef(false) // Apply 时先打标记 const onSelectApplySorts () { isApplyingRef.current true lastSortsRef.current [...localSorts] onApplySorts(localSorts.map((sort) ({ ...sort }))) } useEffect(() { if (isApplyingRef.current) { isApplyingRef.current false; return } // props 变化不是自己造成的 → 认为是外部更新重置草稿 if (!isEqual(sorts, lastSortsRef.current)) { setLocalSorts(sorts) lastSortsRef.current sorts } }, [sorts])自己点 Apply 引发的 props 回灌不会重置草稿避免拖拽状态被打回原形而 URL 被外部改变后退/前进、深链跳转时草稿会同步过来。hasChanges的比较也只针对真正影响排序语义的字段column顺序与ascending用于控制 Apply 按钮的启用状态。3大表排序警告。组件通过useTableRowsCountQuery拿到行数超过THRESHOLD_COUNT定义于 table-row-query.ts值为100000即视为大表。此时若排序列中混入非主键列会先弹出确认框Be careful with sorting on unindexed columns ... This may adversely impact your database, in particular if your table has a large number of rows.并把用户引导到索引管理页查看可安全排序的列。这是 UI 层直接保护后端 Postgres 的一道防线。另外JSON/JSONB 类型列在排序下拉中被禁用disabled: x.dataType json || x.dataType jsonb提示Sorting on JSON-based columns is currently not supported。五、数据流全景与本地持久化把 README 的 6 步数据流映射到具体代码后完整链路是URL (?sort...filter...) │ useTableEditorFiltersSort 读取 ▼ 原始字符串数组 (string[]) │ formatSortURLParams / formatFilterURLParams含列名校验 ▼ 类型化对象 Sort[] / Filter[] ──► FilterPopoverPrimitive / SortPopoverPrimitive草稿态 │ │ 用户编辑草稿 → 点 Apply │ ▼ │ onApplySorts / onApplyFilters ▼ filtersToUrlParams / sortsToUrlParams URL 更新shallow push不进历史栈堆积 │ ├──► useSyncTableEditorStateFromLocalStorageWithUrl同步写 localStorage/sessionStorage └──► react-query 以新的 filters/sorts 重新查询行数据5.1 URL 与 localStorage 的双写SupabaseGrid.utils.ts 中还有一组跨会话记忆工具saveTableEditorStateToLocalStorage以${STORAGE_KEY_PREFIX}_${projectRef}为 key、tableId为子键把每个表的{ gridColumns, sorts, filters, sensitiveDataColumns }SavedState结构见 types/base.ts序列化写入localStorage 和 sessionStorage 双份注释说明sessionStorage 优先读取保证与当前 tab 一致空字符串项会被过滤saveTableEditorStateToLocalStorageDebounced用AwesomeDebouncePromise包了一层500ms 防抖避免频繁写存储useSyncTableEditorStateFromLocalStorageWithUrl监听 URL 中sort/filter变化后直接从window.location.search取最新参数注释指出 nuqs 的useQueryStates参数可能滞后故用useSearchParams 原生URLSearchParams兜底再落盘buildTableEditorUrl反向构造——给定projectReftableId可选schema从 localStorage 读回上次的 sorts/filters 并逐个url.searchParams.append(sort/filter)生成一条还原上次视图的编辑器链接。这就是内部跳转如从对象详情页跳回表编辑器能带出历史视图的原因。5.2 过滤生命周期的初始化与反向同步useFilterLifeCycle.ts 补齐了快照模式下的两段生命周期// 挂载时URL - 快照处理书签/带过滤条件的深链只跑一次 export function useInitializeFiltersFromUrl() { /* formatFilterURLParams → snap.setFilters */ } // 运行期快照 - URL单向500ms 防抖且过滤掉 value 为 /null/undefined 的空条件 export function useSyncFiltersToUrl() { /* 序列比较去重 → filtersToUrlParams → setParams */ }两个细节值得注意useSyncFiltersToUrl用JSON.stringify前后值比较做去重防止无变化的重复 URL 写入同步前会把 value 为空的过滤条件剔除避免 URL 中出现column:eq:这种无效项——这与formatFilterURLParams的防御性解析互为表里。六、消费方使用模式README 给出的标准用法是把两个 Hook 组合进表格容器组件再透传给 Popoverfunction TableComponent() { const { filters, onApplyFilters } useTableFilter() const { sorts, onApplySorts } useTableSort() return ( FilterPopoverPrimitive filters{filters} onApplyFilters{onApplyFilters} / SortPopoverPrimitive sorts{sorts} onApplySorts{onApplySorts} / {/* 表格渲染时应用 filters 与 sorts */} / ) }当前仓库中该模式的实例就是 SortPopoverPrimitive.tsx 本身它内部还调用useTableFilter()取filters一并传给useTableRowsCountQuery计算过滤后的行数用于大表判断——过滤与排序状态在此处合流共同驱动数据查询。七、关键设计要点复盘URL 即状态天然可分享过滤/排序全部可序列化进查询参数sortcol:asc、filtercol:abbrev:value书签、后退前进、跨 tab 深链全部免费获得解析端强防御畸形参数、不存在的列、未知操作符一律静默丢弃formatSortURLParams/formatFilterURLParamsURL 被手改不会污染 UI 状态草稿-应用隔离编辑Popover 内的增删改、拖拽排序全部发生在本地草稿上Apply 前外部状态零扰动Apply 按钮在草稿与 props 等价时自动禁用自我提交与外部更新的区分isApplyingRef/lastSortsRef双 ref 机制解决了自己的回灌被误判为外部变更导致的草稿重置问题持久化分层会话内高频状态走内存快照valtio 500ms 防抖同步 URL跨会话视图记忆走 localStorage/sessionStorage 双写buildTableEditorUrl负责还原性能保护前置到 UI行数超过 100000 的非主键列排序需二次确认JSON/JSONB 列禁止排序避免把一次 UI 点击变成一次全表排序查询。参考文件索引开发者笔记本文主体来源hooks/README.md过滤 HookuseTableFilter.ts排序 HookuseTableSort.ts过滤生命周期URL 初始化 / 同步useFilterLifeCycle.tsURL 参数转换与本地持久化工具SupabaseGrid.utils.tsURL 参数读写shallow pushuseTableEditorFiltersSort.ts操作符定义Filter.constants.ts过滤弹窗草稿-应用FilterPopoverPrimitive.tsx排序弹窗拖拽 大表保护SortPopoverPrimitive.tsxFilter/Sort 类型与行数阈值pg-meta/src/query/types.ts、table-row-query.ts【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考