9 在网格中编辑数据#

Interactive Grid(交互式网格)让最终用户在表格形态中查看、筛选、排序、选择、复制粘贴和直接编辑多行数据。它既继承了 Interactive Report(交互式报表)的许多自助分析能力,又增加了更适合数据录入的网格行为,例如键盘导航、单元格选择、滚动分页、冻结列、行级操作,以及一次保存多行新增、修改和删除。

本章的学习重点不是“把表格变成可编辑”这么简单,而是理解 APEX 在浏览器端如何跟踪网格模型(model)中的行状态、服务器端如何用 Interactive Grid - Automatic Row Processing (DML) 保存变化,以及在集合(Collection)、弹窗编辑、局部刷新等特殊场景下,什么时候需要覆盖默认保存或调用 JavaScript API。

学习前提:建议先准备一个可练习的 APEX 应用,并能访问示例 EMPDEPT 或等价表。若要复现集合示例,还需要能创建数据库视图和 PL/SQL package。若只阅读概念,可以先跳过建表和包代码,但要保留“网格主键必须稳定”这一核心约束。

9.1 认识交互式网格#

Interactive Grid 区域把查询结果显示为功能完整的数据网格。用户可以搜索、筛选、排序、分组、聚合、高亮、下载、调整列顺序、保存个性化报表;开发者启用编辑后,用户还可以在页面内新增、更新、复制、删除、刷新和还原行。保存方式有两类:用户点击网格自己的 Save 动作,只保存这个网格的待处理变化;或提交整个页面,让网格变化与表单项、校验和其他页面进程一起处理。

9.1.1 从一个可编辑员工网格开始#

最小可复现场景是基于 EMP 表创建一个 Interactive Grid,并在区域的 Attributes 中启用编辑。运行页面后,用户可以用鼠标或键盘进入某个可编辑单元格,例如把员工 KING 的名称改为 KINGSTON,也可以连续新增、更新或删除多行,最后一次性保存。

图 9-1 可编辑网格允许用户插入、更新、删除多行后统一保存。

复现路径:在 Page Designer 中创建或打开 Employees 页面;区域类型选择 Interactive Grid,数据源指向 EMP 或等价查询;在 Attributes 中启用 Editable;确认主键列配置正确,并检查自动生成的 Interactive Grid - Automatic Row Processing (DML) 页面进程。运行页面后修改一个员工名称并保存。验收点是刷新页面后仍能看到修改结果,且浏览器控制台和 APEX 错误栈没有保存异常。

如果启用滚动分页,网格可以按需加载更多行,同时保持表头可见,适合数据量较大的浏览场景。开发者还可以精确控制哪些列可编辑、哪些行可编辑,以及没有数据时是否自动显示一行空白记录供用户录入。

图 9-2 网格使用滚动分页时,用户滚动即可按需取数。

9.1.2 为可编辑列选择合适控件#

可编辑网格中的一行可以理解为“横向表单”。每个可编辑列都有自己的列类型,单元格进入编辑模式时才显示对应控件。常见控件包括 Text Field、Number Field、Date Picker、Select List、Popup LOV、Switch 和 Checkbox。这样既能保持网格浏览效率,也能给用户熟悉的表单输入体验。

图 9-3 Date Picker 可作为网格列的编辑控件。
图 9-4 Select List 适合有限枚举值的网格列输入。

复现路径:在 Employees 网格中选择 HIREDATE 列,设置为 Date Picker;选择 JOB 或类似枚举列,设置为 Select List 并配置 LOV。运行页面后分别修改日期和职位。验收点是单元格显示正确控件,保存后数据库值符合预期;若 LOV 有返回值和显示值之分,应确认保存的是返回值而不是显示文本。

9.1.3 最终用户能使用哪些网格能力#

Interactive Grid 的功能入口主要来自默认工具栏、Actions 菜单、列标题菜单、键盘快捷键和直接的网格交互。开发者可以决定开放哪些能力。常见能力包括跨行搜索、限制到某列搜索、选择和重排列、调整列宽、冻结列、排序、筛选、聚合、高亮、控制中断、分组、图表、Flashback、保存报表布局、下载、邮件发送、帮助和键盘快捷键。

图 9-5 Actions 菜单集中呈现开发者启用给用户的网格功能。

启用编辑后,网格有导航模式和编辑模式。导航模式用于移动焦点、选择行或单元格、打开行操作和调用菜单命令;编辑模式用于在当前单元格中显示控件。用户可双击单元格、按 Enter 或点击工具栏 Edit 进入编辑模式,按 Esc 返回导航模式。APEX 同一时间只激活一个单元格编辑器,这对性能、键盘操作和可访问性都更稳定。

网格还区分行操作与选择操作。行操作通常作用于当前行,例如新增、复制、删除、刷新、还原;选择操作作用于被选中的行或单元格范围,例如批量复制、删除、刷新、还原、Copy Down、Fill、Clear 和剪贴板操作。若业务需要批量审批、批量刷新图表、把选中主键传给另一区域或跳转页面,应该优先使用网格选择能力,而不是手写 DOM 遍历。

9.1.4 剪贴板、分页和大数据浏览#

Interactive Grid 支持复制、剪切、粘贴、Paste Insert,以及从电子表格拖放或粘贴表格数据。粘贴时,值会像用户手工输入一样进入单元格,因此动态操作和客户端校验仍可能触发。只读行、只读列或不可编辑单元格会被跳过。对 LOV 列要格外小心:粘贴值应匹配 APEX 实际保存的返回值,而不仅是用户看到的显示值。

剪贴板 API 还受浏览器和部署环境影响。某些浏览器可能要求 HTTPS、用户快捷键触发或明确授权,才允许页面读取剪贴板。正式业务中,批量粘贴前应用小样本验证 LOV、日期格式、数字格式和只读规则。

对较大结果集,网格既可以按页浏览,也可以滚动分页。滚动分页只取当前视口附近需要的行。若关闭 Show Total Count,滚动条只能表达“是否还有更多行”;若开启,APEX 会额外计算总行数并可使用虚拟滚动,滚动条能更准确反映整体数据规模,但代价是多一次计数查询。

图 9-6 滚动较大数据集时,Interactive Grid 按需获取更多记录。

复现路径:使用 Employees 示例数据或等价的较大数据集;在网格 Attributes 中配置分页和表头固定方式。Heading > Fixed To 可选择 PageRegionNone;若选择 Region,再设置 Fixed Report Height,例如 300px。验收点是滚动时表头行为符合配置,且数据按需加载而不是一次性渲染全部记录。

9.1.5 表单控件、级联行为和性能边界#

网格列控件可以参与许多表单式行为。例如级联 LOV 可依赖同一行的其他列值;动态操作可在列值变化时做校验、计算另一列、启用或禁用相关字段,或刷新依赖 LOV。这样,一行网格就像一个按需展开的小型表单。

图 9-7 Popup LOV 可用于网格列中的搜索式选择。

注意:如果级联 LOV 或动态操作每改一行都要访问服务器,批量粘贴、Copy Down、Fill 和 Clear 会明显变慢。能在客户端完成的简单计算和格式检查,可以先用 JavaScript 完成;真正的权威校验和重算仍应在保存时由页面进程、包 API 或数据库约束完成。

9.1.6 在网格列中使用 Template Component#

Template Component(模板组件)适合把可复用的 HTML/SVG 片段封装成组件,让其他开发者在 Page Designer 中通过属性传值。官方示例使用 Percent Dot on Bar 模板组件,把员工薪资在全体薪资范围中的相对位置显示成一条横线上的圆点。

<svg xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 100 16" width="100%" height="16">
  <title>#LABEL#</title>
  <rect x="0" y="6.5" width="100" height="3" rx="1.5" fill="#e8e8e8"/>
  <rect x="0" y="6.5" width="#PERCENT#" height="3" rx="1.5" fill="#378ADD"/>
  <circle cx="#PERCENT#" cy="8" r="6" fill="white" stroke="#185FA5" stroke-width="2"/>
</svg>

viewBox="0 0 100 16" 建立的是作者使用的逻辑坐标系,width="100%"height="16" 决定浏览器如何把逻辑坐标映射到实际页面。因为圆点半径为 6 且边框为 2,视觉上靠近左右边缘时容易被裁切,所以示例把圆心的有效范围设为 7 到 93,也就是 86 个可绘制单位。

图 9-8 Percent Dot on Bar 使用的 SVG viewBox 坐标区域。

复现路径:先创建 Template Component,并定义 LABELPERCENT 自定义属性。随后在 Interactive Grid 的 Columns 下新增隐藏列 PERCENT,类型使用 SQL Expression,数据类型设为 NUMBER,SQL 表达式如下:

ROUND(
  7 + (
    (SAL - MIN(SAL) over ())
    /
    NULLIF(MAX(SAL) over () - MIN(SAL) over (), 0)
    *
    86
  )
)

这个表达式先计算当前 SAL 在最小薪资和最大薪资之间的相对位置,再乘以 86 映射到可绘制宽度,最后加 7 防止圆点贴边被裁切。若所有薪资都相同,NULLIF(..., 0) 可避免除以零。

再新增一个无数据源列作为显示列,将 Source > Type 设为 None,列类型设为 HTML Expression,表达式如下:

{with/}
    LABEL   := &SAL.
    PERCENT := &PERCENT.
{apply PERCENT_DOT_ON_BAR/}
图 9-9 使用 Template Component 将每名员工薪资显示为范围条上的圆点。

验收点:网格仍可编辑原始数据列;薪资范围列只负责展示;鼠标悬停时能通过 LABEL 看到传入的薪资值。正式使用时还应补充可访问性属性,并决定复制单元格时应复制显示 HTML、标签,还是底层数据值。

9.2 使用网格编辑集合数据#

APEX Collection 适合在当前会话中临时保存多行值,例如购物车、向导暂存、尚未提交的批量修改。通常 Collection 由 PL/SQL 代码读写,但也可以让用户通过 Interactive Grid 直接编辑集合成员。官方示例是一个购物车:每行包含商品代码、数量和需要日期;商品代码可以用 Select List 或 Popup LOV,日期可以用 Date Picker。

图 9-10 使用 Interactive Grid 编辑购物车 Collection 行。

直接维护表或可更新视图时,Interactive Grid 的自动 DML 页面进程通常足够;但 Collection 不是普通表,保存时要调用 APEX_COLLECTION API。因此本节的关键是:给每个集合成员生成一个不会变化的业务主键,再用自定义 PL/SQL 保存新增、修改和删除。

9.2.1 网格主键不能随意变化#

Interactive Grid 在浏览器端维护一个客户端模型,用主键列值唯一识别每行。可编辑模型还会跟踪用户新增、修改和删除的行。用户提交页面或点击网格 Save 后,模型中的变化被发送到服务器,由页面进程逐行处理。

模型假设主键值稳定。只有一种例外:用户新增行时,浏览器先生成临时唯一键;服务器保存成功后,数据库分配的真正主键可以替换这个临时键。除此之外,已经存在的行不应在网格编辑过程中改变主键。

Collection 的 SEQ_ID 是系统分配的整数序号,添加、删除、移动或排序集合成员时都可能变化,因此不能把它当作 Interactive Grid 的稳定主键。示例做法是在集合的额外 VARCHAR2 列中保存 SYS_GUID() 生成的值,并把它暴露为网格的 ID 主键列。

  • C001 映射为 ITEM_CODE
  • N001 映射为 QUANTITY
  • D001 映射为 NEED_BY_DATE
  • C002 映射为稳定主键 ID

9.2.2 用视图和包封装 Collection 访问#

先创建一个视图,把 APEX_COLLECTIONS 的通用列名改成业务列名。这样 Interactive Grid 的 SQL 数据源、列属性和后续代码都更容易读懂。

create or replace view shopping_cart_v as
select c001 as item_code,
       c002 as id,
       n001 as quantity,
       d001 as need_by_date
  from apex_collections
 where collection_name = 'SHOPPING_CART'

再创建一个 PL/SQL 包,把清空购物车、新增项目、更新项目、删除项目这些操作封装起来。页面进程只调用业务含义明确的过程或函数,而不直接散落 APEX_COLLECTION 调用。

create or replace package shopping_cart_api is
    procedure clear_cart;

    function add_item(
        p_item_code    in varchar2,
        p_quantity     in number,
        p_need_by_date in date)
        return varchar2;

    procedure remove_item(
        p_item_id in varchar2);

    procedure update_item(
        p_item_id      in varchar2,
        p_item_code    in varchar2,
        p_quantity     in number,
        p_need_by_date in date);
end shopping_cart_api;
create or replace package body shopping_cart_api is
    c_collection_name constant varchar2(40) := 'SHOPPING_CART';

    procedure ensure_collection is
    begin
        if not apex_collection.collection_exists(c_collection_name) then
            apex_collection.create_collection(c_collection_name);
        end if;
    end ensure_collection;

    procedure clear_cart is
    begin
        ensure_collection;
        apex_collection.truncate_collection(c_collection_name);
    end clear_cart;

    function get_seq_id(p_item_id in varchar2)
        return number
    is
        l_ret number;
    begin
        for j in (
            select seq_id as seq
              from apex_collections
             where collection_name = c_collection_name
               and c002 = p_item_id
        ) loop
            l_ret := j.seq;
        end loop;
        return l_ret;
    end;

    function add_item(
        p_item_code    in varchar2,
        p_quantity     in number,
        p_need_by_date in date)
        return varchar2
    is
        l_ret varchar2(255) := sys_guid();
    begin
        ensure_collection;
        apex_collection.add_member(
            p_collection_name => c_collection_name,
            p_c001            => p_item_code,
            p_c002            => l_ret,
            p_n001            => p_quantity,
            p_d001            => p_need_by_date);
        return l_ret;
    end add_item;

    procedure remove_item(p_item_id in varchar2) is
        l_seq_id number;
    begin
        ensure_collection;
        l_seq_id := get_seq_id(p_item_id);
        if l_seq_id is not null then
            apex_collection.delete_member(
                p_collection_name => c_collection_name,
                p_seq             => l_seq_id);
        end if;
    end remove_item;

    procedure update_item(
        p_item_id      in varchar2,
        p_item_code    in varchar2,
        p_quantity     in number,
        p_need_by_date in date)
    is
        l_seq_id number;
    begin
        ensure_collection;
        l_seq_id := get_seq_id(p_item_id);
        if l_seq_id is not null then
            apex_collection.update_member(
                p_collection_name => c_collection_name,
                p_seq             => l_seq_id,
                p_c001            => p_item_code,
                p_c002            => p_item_id,
                p_n001            => p_quantity,
                p_d001            => p_need_by_date);
        end if;
    end update_item;
end shopping_cart_api;

复现路径:在 SQL Workshop 或部署脚本中创建视图和包;确认当前会话可以查询 shopping_cart_v;调用 shopping_cart_api.add_item 后再查视图,应看到新行且 ID 有 GUID 值。验收点是同一个集合成员的 ID 在更新前后保持不变。

9.2.3 让网格通过 API 保存集合行#

视图和包准备好后,把 Interactive Grid 的数据源设为 SHOPPING_CART_V,并把 ID 列配置为 Primary Key。启用编辑后,Page Designer 会创建相关保存进程。可以把它重命名为 Save Shopping Cart,类型仍是 Interactive Grid - Automatic Row Processing (DML)Editable Region 指向该网格,但 Target Type 改为 PL/SQL Code

由于购物车集合数据只属于当前用户会话,不会被其他用户同时锁定或修改,可关闭行锁和 Lost Update Protection。真正业务表的保存仍应保留并发保护。

图 9-11 将网格自动处理进程改为用 PL/SQL 调用购物车 API。

保存进程会针对每一条新增、修改或删除的网格行执行一次。内置绑定变量 :APEX$ROW_STATUS 表示当前行状态:'C' 是创建,'U' 是更新,'D' 是删除。新增时,应把 add_item 返回的 GUID 写回 :ID,让浏览器模型拿到服务器端确认后的稳定主键。

case :APEX$ROW_STATUS
when 'C' /* Create */ then
    :ID := shopping_cart_api.add_item(
        p_item_code    => :ITEM_CODE,
        p_quantity     => to_number(:QUANTITY),
        p_need_by_date => to_date(:NEED_BY_DATE, 'DD-MON-YYYY'));
when 'U' /* Update */ then
    shopping_cart_api.update_item(
        p_item_id      => :ID,
        p_item_code    => :ITEM_CODE,
        p_quantity     => to_number(:QUANTITY),
        p_need_by_date => to_date(:NEED_BY_DATE, 'DD-MON-YYYY'));
when 'D' /* Delete */ then
    shopping_cart_api.remove_item(
        p_item_id => :ID);
end case;

实现提醒:网格中的日期或时间戳列传给 PL/SQL 时通常是 VARCHAR2,应使用与页面格式一致的 to_dateto_timestamp 转换;数字列使用 to_number。捕获源代码里数量转换写成 to_number('QUANTITY'),本草稿按可执行意图写为 to_number(:QUANTITY),最终合并前建议对照 Oracle 原文和本地运行结果确认。

验收点:新增一行后保存,页面不应报主键变化错误;更新数量或日期后保存,集合视图返回新值;删除行后保存,视图中对应 ID 不再存在。

9.2.4 声明式调用网格动作#

购物车页面可以关闭网格工具栏和页脚,改用普通页面按钮组织操作。例如 Save 按钮提交页面保存网格;Clear 按钮提交页面并触发一个 Invoke API 进程调用 shopping_cart_api.clear_cart;自定义 Add Item 按钮则不提交页面,而是在浏览器端触发网格内置的 selection-add-row 动作。

声明式按钮配置方式如下:

  1. 给 Interactive Grid 区域设置 HTML DOM ID,例如 shoppingcart
  2. 把按钮 Action 设为 Defined by Dynamic Action
  3. 在按钮 Custom Attributes 中写入以下属性。
data-action="[shoppingcart]selection-add-row" data-no-update="true"

方括号中是网格区域的 HTML DOM ID,后面是要调用的动作名。data-no-update="true" 表示不要让运行时动作自动覆盖按钮标签或图标。验收点是点击 Add Item 后网格出现新行,但页面不刷新;点击 Save 后才真正写入集合。

9.2.5 空购物车提示和结账处理#

默认没有行时,网格会显示 No data found。业务页面应把 Attributes 中的 When No Data Found 改成更贴近场景的文案,例如 No items in your cart

图 9-12 自定义空数据提示让购物车页面更清楚。

真正结账时,可以遍历视图中的集合成员,再在单一事务中写入订单头、订单行和库存或审批记录。

for j in (
    select item_code, quantity, need_by_date
      from shopping_cart_v
) loop
    -- Reference j.item_code, j.quantity, j.need_by_date
end loop;

复现路径:清空购物车后运行页面,确认空数据提示显示为自定义文案;新增两行并保存,再运行结账处理或测试块读取 shopping_cart_v。验收点是业务代码读取到的列名与页面列一致,不需要知道底层 C001N001D001 这些通用列。

9.3 理解并定制网格行为#

Interactive Grid 是一个 model-view 组件:客户端模型保存记录、元数据和变化状态;视图负责展示和编辑;动作、事件和 widget 方法让开发者调用命令或响应用户交互。工具栏按钮和菜单项本质上连接到命名动作,例如保存、刷新、进入编辑模式、添加行或打开内置对话框。

默认配置能覆盖大多数场景。需要定制时,优先使用公开的 APEX JavaScript API 和区域 Static ID,不要依赖内部 DOM 结构。初始化函数用于网格创建前的配置;运行时 API 用于网格已经存在后的选择、刷新、取值、设值和事件处理。

9.3.1 保存处理与 JavaScript 定制入口#

可编辑网格的客户端模型会跟踪 inserted、updated 和 deleted 记录。触发网格自己的 Save 动作时,只保存该网格的待处理变化且不刷新整页;提交页面时,待处理网格变化可以与其他区域、页面项、校验和页面进程一起保存。主表单加一个或多个明细网格时,常用页面提交来保证一个事务内完成整体校验和保存。

JavaScript 定制常见用途包括:调整默认视图选项、改造工具栏、配置内置动作、增加自定义动作、调用 saverefresheditshow-filter-dialog 等动作,响应选择变化、模式变化、保存、报表变化和视图变化,或读取模型并刷新指定行。

在 Interactive Grid 区域的 Attributes > JavaScript Initialization Function 中,可以设置创建前必须确定的选项。对嵌套选项对象,例如 defaultGridViewOptionsdefaultModelOptions,应使用 apex.util.getNestedObject,避免覆盖 APEX 或其他代码已经设置的同级配置。

9.3.2 几个小型初始化示例#

有些需求只需改一个属性:隐藏页脚用 footer = false;允许多行选择用 multiple = true;把选中主键自动维护到隐藏页面项用 selectionStateItem = "P2_SELECTED_EMPNOS";跨分页保持选择用 persistSelection = true

function( options ) {
    const gridOptions = apex.util.getNestedObject(
        options,
        "defaultGridViewOptions"
    );
    gridOptions.selectionStateItem = "P2_SELECTED_EMPNOS";
    gridOptions.multiple = true;
    // gridOptions.footer = false;
    // gridOptions.persistSelection = true;
    return options;
}

复现路径:给 Employees 网格设置 Static ID,并在 JavaScript Initialization Function 中加入上面代码;页面上创建隐藏项 P2_SELECTED_EMPNOS;运行页面后选择多行。验收点是隐藏项保存选中行主键,且分页切换时是否保留选择与 persistSelection 配置一致。

9.3.3 精确刷新弹窗编辑后的某一行#

Interactive Grid 模型 API 提供 getRecord()fetchRecords()。前者按主键在客户端模型中找记录,后者从服务器重新取回指定记录。组合起来,就能在弹窗编辑返回后只刷新被改动的行;若无法确定主键,例如新增或删除,则刷新整个区域。

function refreshGridRowOrRegion( regionHtmlDomId, primaryKey ) {
    const region = apex.region( regionHtmlDomId );

    if ( !region || region.type !== "InteractiveGrid" ) {
        throw new Error( "The region must be an Interactive Grid" );
    }

    if ( primaryKey ) {
        const gridView = region.call( "getViews", "grid" );
        const model = gridView.model;
        const record = model.getRecord( primaryKey );

        if ( record ) {
            model.fetchRecords( [ record ] );
            return;
        }
    }

    region.refresh();
}

复现路径:在调用页的 Function and Global Variable Declaration 中加入该函数;Employees 网格设置 Static ID 为 employees;弹窗页负责创建、编辑或删除员工。验收点是编辑已有员工并关闭弹窗后,仅对应行刷新;新增或删除时,整个网格刷新以保持行集合准确。

在调用页添加动态操作:事件选择 Dialog Closed,选择类型用 jQuery selector,目标为 body;动作类型为 Execute JavaScript Code,代码如下:

const editedEmpno = this.data && this.data.P2_EMPNO_EDITED;
refreshGridRowOrRegion( "employees", editedEmpno || null );
图 9-13 在 Dialog Closed 动态操作中调用帮助函数刷新网格行。

9.3.4 让弹窗返回被编辑主键#

为了让调用页知道该刷新哪一行,弹窗页可以维护一个隐藏项 P2_EMPNO_EDITED。编辑已有员工时,在 Pre-Rendering 阶段用 computation 把传入的 P2_EMPNO 复制到 P2_EMPNO_EDITED

图 9-14 用计算把正在编辑的主键保存到隐藏项。

弹窗页关闭时,Close Dialog 页面进程的 Items to Return 配置为返回 P2_EMPNO_EDITED。调用页的 this.data.P2_EMPNO_EDITED 就能读到该值。

图 9-15 Close Dialog 进程把隐藏项作为返回值传回调用页。

删除时不应返回被删除行的主键,因为调用页已经不能只刷新那一行。可以在关闭弹窗前增加一个类型为 Clear Session State 的页面进程,例如 Clear EMPNO_EDITED for Delete,并设置在 DELETE 按钮按下时执行,清空 P2_EMPNO_EDITED

图 9-16 删除记录时清空返回主键,让调用页刷新整个网格。

还可以通过服务器端条件避免用户看到数据库外键错误。例如只有当当前员工没有被其他员工作为经理引用时才显示 Delete 按钮。按钮的 Server-side Condition 可使用 No Rows Returned,SQL 如下:

select empno
  from emp
 where mgr = :P2_EMPNO

验收点:编辑现有员工后,调用页只刷新该员工行;新增员工或删除员工后,调用页刷新整个网格;当员工仍被其他记录引用为经理时,删除按钮不显示,用户不会走到外键约束报错。