Approval Process Handler
Requirements
Supported Environment
- Private deployment
- Web
ONES Version
7.x
Dependency Versions
{
"devDependencies": {
"@ones/cli-plugin": "1.70.55"
},
"dependencies": {
"@ones-op/bridge": "^1.0.0",
"@ones-op/fetch": "1.70.55",
"@ones-op/node-types": "^1.0.0",
"@ones-op/sdk": "1.70.55",
"react": "~17.0.2",
"react-dom": "~17.0.2"
}
}
Capability Overview
The approval process handler allows a plugin to participate in approval rule configuration, approve or reject operations, and approval flow result display. A plugin can use these integration points to implement its own business process, such as additional business confirmation, external system processing, business credential generation, or extension result display.
The plugin defines the actual business behavior. ONES provides standardized integration points and contracts without restricting the extension to one specific scenario.
An approval rule can bind at most one approval process Provider. ONES remains responsible for approval comments, state changes, and flow progression. The plugin stores and interprets its own business state and results.
Capability Boundaries
- Only
approveandrejectcan be intercepted. - Add approver, transfer, and revoke continue to use the standard ONES flow.
- Single and batch approvals use the same operation takeover slot.
- The current version provides Web slots only. Mobile and H5 are not supported.
- The plugin must complete its work on the current approval page and call the host-provided
onSubmit. - Page navigation recovery, new-window recovery, and asynchronous server callbacks that advance approval are not supported.
- A plugin must not modify ONES approval data directly or bypass
onSubmitby calling internal approval APIs.
How It Works
The approval process handler contains three frontend slots and one backend validation function:
| Integration Point | Name | Purpose |
|---|---|---|
| Rule settings slot | ones:approval:rule-plugin-settings | Configure intercepted actions and plugin settings in More Settings |
| Operation takeover slot | ones:approval:operation-takeover | Render plugin UI when the user clicks Approve or Reject |
| Flow row extra slot | ones:approval:flow-row-extra | Render plugin results in the last approval flow column |
| Backend function | validate | Verify plugin business completion before ONES writes the approval comment |
The end-to-end flow is:
- An administrator selects a plugin in an approval rule and saves
enabledActionsandsettingsthrough the rule settings slot. - ONES saves the configuration with the approval rule version. An approval instance continues to use the rule version and settings frozen when it was created.
- When an approver clicks an intercepted action, ONES loads the operation takeover slot instead of the standard comment popover.
- The plugin completes and persists its own business result, then calls
onSubmit({ comment }). onSubmitcontinues the standard ONES approval request. Before writing the approval comment, ONES calls the Provider'svalidate.- If validation passes, ONES writes the comment and advances the flow. Otherwise, the current item is blocked.
- When approval detail is opened, ONES loads the flow row extra slot for each visible row, and the plugin queries and renders its result.
Persist the plugin business result before calling onSubmit, because ONES invokes validate while onSubmit is executing.
Configure the Extension
Declare approvalProcessHandler in config/plugin.yaml. The following configuration also declares two plugin APIs and an entity used by the runnable demo.
The current Plugin 1.0 CLI does not generate feature extension configuration automatically. Add the following content to config/plugin.yaml manually.
apis:
- type: addition
methods:
- POST
url: /approval/completion/mark
function: markApprovalCompletion
- type: addition
methods:
- POST
url: /approval/completion/get
function: getApprovalCompletion
extension:
- approvalProcessHandler:
provider: aprvV1D1
funcs:
- name: validate
url: validate
slots:
- name: ones:approval:rule-plugin-settings
entryUrl: modules/approval-rule-settings-v1/index.html
- name: ones:approval:operation-takeover
entryUrl: modules/approval-operation-takeover-v1/index.html
- name: ones:approval:flow-row-extra
entryUrl: modules/approval-flow-row-extra-v1/index.html
config:
displayName: Plugin 1.0 Approval Demo
supportedActions:
- approve
- reject
flowColumnWidth: 120
storage:
entities:
- name: approval_completion_v1
attributes:
approval_uuid:
type: string
length: 64
required: true
node_uuid:
type: string
length: 64
required: true
opinion_uuid:
type: string
length: 64
required: true
action:
type: string
length: 16
required: true
comment:
type: text
required: false
default: ''
result:
type: string
length: 80
required: true
updated_at:
type: string
length: 32
required: true
Configuration Constraints
| Field | Requirement |
|---|---|
service.app_id | Exactly eight alphanumeric characters; use the production App ID created by ones create |
provider | Exactly eight alphanumeric characters; do not change it after publishing |
funcs[].name | Must be validate |
funcs[].url | Must be the plugin function name validate, not /approval/validate |
slots[].entryUrl | Must point to modules/<module-id>/index.html in the package |
displayName | Non-empty after trimming, at most 80 characters |
supportedActions | At least one unique value; only approve and reject are accepted |
flowColumnWidth | Optional, default 100; an explicit value must be an integer from 80 to 160 |
settings must be a JSON object no larger than 32 KiB after serialization. Do not store secrets, access tokens, or per-approval runtime results in it.
validate is registered through extension.funcs and invoked by the ONES backend. It does not need to be declared again as a regular HTTP route under apis. The two apis in this example are only used by the plugin frontend to save and query Demo business results.
The following code forms a runnable Plugin 1.0 demo. The extension name, configuration constraints, Slot Contexts, and validate contract are part of the public capability protocol. The demo API paths, Entity Storage schema, labels, styles, and response unwrapping can be replaced for the plugin's business needs.
Public Types
Create web/src/approval/types.ts:
export type ApprovalPluginAction = 'approve' | 'reject'
export interface ApprovalRulePluginSettingsValue {
enabledActions: ApprovalPluginAction[]
settings: Record<string, unknown>
}
export interface ApprovalRulePluginSettingsContext {
mode: 'create' | 'edit' | 'copy'
supportedActions: ApprovalPluginAction[]
value: ApprovalRulePluginSettingsValue
onChange(value: ApprovalRulePluginSettingsValue): Promise<void>
onInit(config: { validate?: () => Promise<void> }): void
}
export interface ApprovalOperationTarget {
approvalUUID: string
nodeUUID: string
opinionUUID: string
ruleUUID: string
ruleVersion: number
}
export interface ApprovalOperationTakeoverContext {
action: ApprovalPluginAction
commentRequired: boolean
settings: Record<string, unknown>
approvals: ApprovalOperationTarget[]
onSubmit(value: { comment?: string }): Promise<{
succeededApprovalUUIDs: string[]
failedApprovalUUIDs: string[]
}>
onCancel(): void
}
export type ApprovalFlowRowType =
| 'submit'
| 'opinion'
| 'pending-users'
| 'cc'
| 'execute'
| 'revoke'
export interface ApprovalFlowRowExtraContext {
settings: Record<string, unknown>
approvalUUID: string
row: {
rowType: ApprovalFlowRowType
nodeUUID?: string
opinionUUID?: string
}
}
Implement the Rule Settings Slot
Create web/src/approval/approval-rule-settings.tsx:
import { useExtensionContext } from '@ones-op/sdk'
import React, { useEffect, useRef, useState } from 'react'
import type {
ApprovalPluginAction,
ApprovalRulePluginSettingsContext,
ApprovalRulePluginSettingsValue,
} from './types'
export default function ApprovalRuleSettings() {
const context = useExtensionContext<ApprovalRulePluginSettingsContext>()
const supportedActions = context.supportedActions ?? []
const initialValue = context.value ?? {
enabledActions: supportedActions,
settings: { source: 'plugin-v1-demo' },
}
const [value, setValue] = useState<ApprovalRulePluginSettingsValue>(initialValue)
const valueRef = useRef(value)
useEffect(() => {
valueRef.current = value
}, [value])
useEffect(() => {
context.onInit({
validate: async () => {
if (valueRef.current.enabledActions.length === 0) {
throw new Error('Select at least one approval action')
}
},
})
}, [context])
const updateAction = async (action: ApprovalPluginAction, checked: boolean) => {
const enabledActions = checked
? [...new Set([...value.enabledActions, action])]
: value.enabledActions.filter((item) => item !== action)
if (enabledActions.some((item) => !supportedActions.includes(item))) {
throw new Error('The plugin does not support this action')
}
const next = {
enabledActions,
settings: {
...value.settings,
source: 'plugin-v1-demo',
},
}
await context.onChange(next)
setValue(next)
}
return (
<section>
<p>Mode: {context.mode}. Select intercepted actions.</p>
{supportedActions.map((action) => (
<label key={action} style={{ display: 'block' }}>
<input
type="checkbox"
checked={value.enabledActions.includes(action)}
onChange={(event) => void updateAction(action, event.target.checked)}
/>
Intercept {action}
</label>
))}
</section>
)
}
Context Fields
| Field | Description |
|---|---|
mode | Rule entry: create, edit, or copy |
supportedActions | Maximum actions declared by the Provider |
value.enabledActions | Actions enabled by the current rule; must be a subset of supportedActions |
value.settings | Plugin business settings for the current rule |
onChange | Sends the complete enabledActions and settings value to the host |
onInit | Registers an optional rule settings validator |
Call onInit once after initialization. The host invokes the registered validator before leaving More Settings and before the final rule save. Always send the complete value to onChange, not a partial patch.
mode tells the plugin how to initialize the rule settings:
create: Initialize defaults for a new rule.edit: Display and update the existing value.copy: The value comes from the source rule. Preserve reusable settings and reset external bindings, business identifiers, or one-time data that must not be copied.
Do not infer the entry mode only from whether value is empty. Otherwise a copied rule can be mistaken for an edit and reuse the source rule's external business binding, or an existing rule can be reinitialized and lose its configuration.
Implement the Operation Takeover Slot
When the user clicks an intercepted Approve or Reject action, ONES loads this slot instead of the standard comment popover. After completing its own business process, the plugin must call the host onSubmit to continue standard approval.
Context Fields
| Field | Description |
|---|---|
action | Current action, either approve or reject |
commentRequired | Whether the current rule requires a comment; the plugin controls its UI and the host validates again |
settings | Plugin rule settings frozen when the approval was created |
approvals | Single or batch approval targets; its length is 1 for a single approval |
onSubmit | Submits standard approval through the host and returns succeeded and failed approval UUIDs |
onCancel | Cancels this takeover without changing approval state |
Each target in approvals identifies the approval, current node, current opinion, and frozen rule version. A plugin typically associates its business result with approvalUUID + opinionUUID + action.
Recommended processing order:
- Validate plugin UI input.
- Complete and persist plugin business processing for the targets in
approvals. - Ensure the plugin backend can expose the completed state to
validate. - Call
onSubmit({ comment })once for the whole batch. - Use the succeeded and failed UUIDs to update plugin state or prompt for a retry.
Demo: Call the Plugin Backend
Create web/src/approval/plugin-api.ts:
import { OPFetch } from '@ones-op/fetch'
import type { ApprovalOperationTarget, ApprovalPluginAction } from './types'
function parseJSON(value: unknown): unknown {
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return value
}
}
function readBody<T>(response: unknown): T {
let value = response
for (let depth = 0; depth < 4; depth += 1) {
value = parseJSON(value)
if (!value || typeof value !== 'object') break
if ('body' in value) {
value = (value as { body?: unknown }).body
} else if ('data' in value) {
value = (value as { data?: unknown }).data
} else {
break
}
}
return parseJSON(value) as T
}
export async function markApprovalCompletions(
action: ApprovalPluginAction,
comment: string,
approvals: ApprovalOperationTarget[],
): Promise<void> {
await OPFetch('/project/api/project/approval/completion/mark', {
method: 'POST',
data: { action, comment, approvals },
})
}
export async function getApprovalCompletion(
approvalUUID: string,
opinionUUID: string,
): Promise<string | null> {
const response = await OPFetch('/project/api/project/approval/completion/get', {
method: 'POST',
data: { approvalUUID, opinionUUID },
})
return readBody<{ value?: string | null }>(response)?.value ?? null
}
Plugin 1.0 gateways may preserve or serialize PluginResponse.body. The demo therefore supports data, body, and JSON string wrappers. This unwrapping helper is part of the reference implementation, not the approval process handler contract.
Demo: Takeover UI
Create web/src/approval/approval-operation-takeover.tsx:
import { useExtensionContext } from '@ones-op/sdk'
import React, { useState } from 'react'
import { markApprovalCompletions } from './plugin-api'
import type { ApprovalOperationTakeoverContext } from './types'
import './approval-operation-takeover.css'
export default function ApprovalOperationTakeover() {
const context = useExtensionContext<ApprovalOperationTakeoverContext>()
const [comment, setComment] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const submit = async () => {
if (submitting) return
const normalizedComment = comment.trim()
if (context.commentRequired && !normalizedComment) {
setError('Enter an approval comment')
return
}
setSubmitting(true)
setError('')
try {
// Persist plugin state before ONES calls validate.
await markApprovalCompletions(context.action, normalizedComment, context.approvals)
const result = await context.onSubmit({
comment: normalizedComment,
})
if (result.failedApprovalUUIDs.length > 0) {
setError(`${result.failedApprovalUUIDs.length} approval items failed`)
setSubmitting(false)
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Approval operation failed')
setSubmitting(false)
}
}
return (
<div className="approval-plugin-modal-mask">
<section role="dialog" aria-modal="true">
<h2>Plugin 1.0 Approval Takeover</h2>
<p>
Action: {context.action}; approvals:
{context.approvals.length}
</p>
<textarea
value={comment}
disabled={submitting}
onChange={(event) => setComment(event.target.value)}
/>
{error ? <p role="alert">{error}</p> : null}
<button disabled={submitting} onClick={context.onCancel}>
Cancel
</button>
<button disabled={submitting} onClick={() => void submit()}>
Confirm
</button>
</section>
</div>
)
}
Use fixed positioning and a sufficient stack level for the modal mask:
.approval-plugin-modal-mask {
position: fixed;
z-index: 999;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 32, 44, 0.48);
}
onSubmit Semantics
- For a single approval,
approvals.lengthis1. - Batch approval loads the slot once, with all applicable targets in
approvals. - Call
onSubmitonly once after plugin processing for the whole batch is complete. onSubmitreuses standard ONES parameter validation, duplicate submission prevention, messages, batch result handling, and detail refresh.- If all items fail,
onSubmitrejects. It resolves for full or partial success. - Handle batch results using
succeededApprovalUUIDsandfailedApprovalUUIDs. onCancelcloses the plugin UI without changing approval state.
Implement the Flow Row Extra Slot
After the approval process handler is enabled, ONES adds an extra column after the approval flow date and loads this slot for each visible flow row. The plugin queries its backend from the Context and decides what to render in the current cell.
Context Fields
| Field | Description |
|---|---|
settings | Plugin rule settings frozen when the approval was created |
approvalUUID | Current approval UUID |
row.rowType | Current flow row type |
row.nodeUUID | Current node UUID; available only for some row types |
row.opinionUUID | Current opinion UUID; available for real approval opinion rows |
Demo: Flow Row Extra
Create web/src/approval/approval-flow-row-extra.tsx:
import { useExtensionContext } from '@ones-op/sdk'
import React, { useEffect, useState } from 'react'
import { getApprovalCompletion } from './plugin-api'
import type { ApprovalFlowRowExtraContext } from './types'
export default function ApprovalFlowRowExtra() {
const context = useExtensionContext<ApprovalFlowRowExtraContext>()
const [value, setValue] = useState<string | null>(null)
useEffect(() => {
let active = true
const opinionUUID = context.row?.opinionUUID
if (context.row?.rowType !== 'opinion' || !context.approvalUUID || !opinionUUID) {
setValue(null)
return () => {
active = false
}
}
void getApprovalCompletion(context.approvalUUID, opinionUUID)
.then((result) => {
if (active) setValue(result)
})
.catch((reason) => {
console.error('Failed to load approval extension result', reason)
if (active) setValue(null)
})
return () => {
active = false
}
}, [context.approvalUUID, context.row?.opinionUUID, context.row?.rowType])
return value ? <span>{value}</span> : null
}
ONES loads this slot for each visible flow row. Not every row has a nodeUUID or opinionUUID:
rowType | Available Identity |
|---|---|
submit | approvalUUID |
opinion | approvalUUID, nodeUUID, opinionUUID |
pending-users | approvalUUID, nodeUUID |
cc | approvalUUID, nodeUUID |
execute | approvalUUID, nodeUUID |
revoke | approvalUUID |
The demo renders a result only for real opinion rows. A production plugin can choose other rows, but must not assume every row has an opinion UUID.
Register the Frontend Modules
Create source directories matching the three entryUrl values:
web/src/modules/
├── approval-rule-settings-v1/index.tsx
├── approval-operation-takeover-v1/index.tsx
└── approval-flow-row-extra-v1/index.tsx
Each entry uses OPProvider to provide extension Context. The rule settings entry is:
import { lifecycle, OPProvider } from '@ones-op/bridge'
import React from 'react'
import ReactDOM from 'react-dom'
import ApprovalRuleSettings from '../../approval/approval-rule-settings'
const root = document.getElementById('ones-mf-root')
if (!root) throw new Error('Plugin module root is missing')
ReactDOM.render(
<OPProvider>
<ApprovalRuleSettings />
</OPProvider>,
root,
)
lifecycle.onDestroy(() => {
ReactDOM.unmountComponentAtNode(root)
})
Use the same structure in the other two entries to render ApprovalOperationTakeover and ApprovalFlowRowExtra.
Implement the Plugin Backend
The demo stores plugin results in Entity Storage. A production implementation can use another plugin-owned backend store, but validate and flow display must query the same authoritative data source.
Keep the generated lifecycle functions in backend/src/index.ts and add:
import type { PluginRequest, PluginResponse } from '@ones-op/node-types'
import { storage } from '@ones-op/sdk/backend'
import { createHash } from 'node:crypto'
type Action = 'approve' | 'reject'
interface Completion {
approval_uuid: string
node_uuid: string
opinion_uuid: string
action: Action
comment: string
result: string
updated_at: string
}
interface ApprovalValidateRequest {
team_uuid?: string
user_uuid?: string
action?: Action
rule?: {
rule_uuid: string
rule_version: number
settings: Record<string, unknown>
}
approval?: {
approval_uuid?: string
node_uuid?: string
opinion_uuid?: string
}
comment?: string
}
const entity = storage.entity<Completion>('approval_completion_v1')
function key(approvalUUID: string, opinionUUID: string): string {
return createHash('sha256').update(approvalUUID).update('\0').update(opinionUUID).digest('hex')
}
function hasPayload(value: unknown): boolean {
if (value == null) return false
if (typeof value === 'string') return value.trim().length > 0
if (value instanceof Uint8Array) return value.length > 0
if (typeof value === 'object') {
return Object.keys(value).length > 0
}
return true
}
function decode<T>(value: unknown): T {
if (typeof value === 'string') return JSON.parse(value)
if (value instanceof Uint8Array) {
return JSON.parse(new TextDecoder().decode(value))
}
return (value ?? {}) as T
}
function requestBody<T>(requestOrBody: PluginRequest | T): T {
const request = requestOrBody as PluginRequest
if (
!requestOrBody ||
typeof requestOrBody !== 'object' ||
!('body' in requestOrBody || 'reqBody' in requestOrBody || 'headers' in requestOrBody)
) {
return requestOrBody as T
}
return decode<T>(hasPayload(request.body) ? request.body : request.reqBody)
}
export async function markApprovalCompletion(request: PluginRequest): Promise<PluginResponse> {
const input = requestBody<{
action: Action
comment?: string
approvals: Array<{
approvalUUID: string
nodeUUID: string
opinionUUID: string
}>
}>(request)
if (!['approve', 'reject'].includes(input.action) || !input.approvals?.length) {
throw new Error('Incomplete approval takeover input')
}
await Promise.all(
input.approvals.map((target) =>
entity.set(key(target.approvalUUID, target.opinionUUID), {
approval_uuid: target.approvalUUID,
node_uuid: target.nodeUUID,
opinion_uuid: target.opinionUUID,
action: input.action,
comment: input.comment?.trim() ?? '',
result: 'Plugin 1.0 Demo',
updated_at: new Date().toISOString(),
}),
),
)
return {
body: {
markedApprovalUUIDs: input.approvals.map((target) => target.approvalUUID),
},
}
}
export async function getApprovalCompletion(request: PluginRequest): Promise<PluginResponse> {
const input = requestBody<{
approvalUUID?: string
opinionUUID?: string
}>(request)
if (!input.approvalUUID || !input.opinionUUID) {
return { body: { value: null } }
}
const record = await entity.get(key(input.approvalUUID, input.opinionUUID))
return { body: { value: record?.result ?? null } }
}
export async function validate(
request: PluginRequest | ApprovalValidateRequest,
): Promise<PluginResponse> {
const input = requestBody<ApprovalValidateRequest>(request)
const approvalUUID = input.approval?.approval_uuid
const opinionUUID = input.approval?.opinion_uuid
if (!approvalUUID || !opinionUUID || !input.action) {
return {
body: {
error: {
level: 'error',
reason: 'Incomplete plugin approval context',
},
},
}
}
const record = await entity.get(key(approvalUUID, opinionUUID))
if (!record || record.action !== input.action) {
return {
body: {
error: {
level: 'error',
reason: 'Complete the required plugin process first',
},
},
}
}
return { body: {} }
}
validate Request
ONES invokes validate after standard permission, state, and current approver checks pass, and before the first approval comment write:
| Field | Description |
|---|---|
team_uuid | Current team UUID |
user_uuid | Current approval operator UUID |
rule.rule_uuid | Approval rule UUID used by the approval |
rule.rule_version | Rule version frozen when the approval was created |
rule.settings | Plugin rule settings frozen when the approval was created |
approval.approval_uuid | Current approval UUID |
approval.node_uuid | Current pending node UUID |
approval.opinion_uuid | Pending opinion UUID for the current operator |
action | Current action: approve or reject |
comment | Comment submitted through onSubmit; it may be absent when no comment exists |
{
"team_uuid": "team_12345678",
"user_uuid": "user_12345678",
"rule": {
"rule_uuid": "rule_12345678",
"rule_version": 2,
"settings": {
"source": "plugin-v1-demo"
}
},
"approval": {
"approval_uuid": "approval_12345678",
"node_uuid": "node_12345678",
"opinion_uuid": "opinion_12345678"
},
"action": "approve",
"comment": "Approved"
}
Return an empty object to pass:
{}
Return this structure to block:
{
"error": {
"reason": "Complete the required plugin process first",
"level": "error"
}
}
reason must be non-empty plain text no longer than 500 characters. level currently only accepts error. Invocation failures, timeouts, invalid JSON, and invalid error structures block the current item.
The frontend loads one slot and calls onSubmit once for a batch, while the ONES backend invokes validate once for every approval item. Keep validate efficient, repeatable, and free of side effects.
Data Storage Requirements
- Persist plugin business results in a plugin backend, not only in
localStorageor current browser state. - Use
approval_uuid + opinion_uuid + actionas a business idempotency identity. - Entity Storage keys must match
^[_a-z0-9]{1,64}$. The example hashes the approval and opinion UUIDs with SHA-256. - Do not use raw UUID concatenation with colons as an Entity Storage key.
- Flow display data must be consistent across users, browsers, and devices.
- Production plugins must verify caller identity and data access permission before returning plugin business results.
Lifecycle and Failure Behavior
| Scenario | ONES Behavior |
|---|---|
| No approval Provider is installed | Use the standard approval flow |
| Provider is disabled or uninstalled | Preserve historical rule binding, restore standard approve/reject, and hide the extra column |
| Provider is enabled but its configuration is invalid | Block intercepted approve or reject |
| Rule settings slot fails to load | Do not allow a new plugin rule configuration to be saved |
| Operation takeover slot fails to load or run | Block approval and do not fall back to the standard comment popover |
validate returns an error, fails, or times out | Block the current approval item |
| Flow row slot fails | Degrade the current extra cell without breaking the rest of approval detail |
Disabling or uninstalling a Provider means the administrator no longer requires it, so ONES restores standard approval. If an enabled Provider fails at runtime, the rule still requires plugin control and ONES does not silently bypass it.
Build and Install
After implementation, generate an installable release package in the plugin project root:
npx op packup --release
A development package may prefix app_id with dev_, which does not satisfy the feature extension's eight-character App ID validation and cannot be uploaded for installation.
Packaging Troubleshooting
If installation, enablement, or Provider discovery fails, inspect the release package:
tar -xOzf <plugin-name>_<version>.opk config/plugin.yaml
Verify:
service.app_idis eight alphanumeric characters without adev_prefix.service.modematches the plugin installation mode.provideris a stable eight-character alphanumeric value.- All three
entryUrlvalues usemodules/<module-id>/index.html. funcs[].urlisvalidate.
Verify the Integration
- Upload, install, and enable the plugin. Select its Provider in the approval rule's More Settings step and publish the rule.
- Verify single Approve, single Reject, and batch approval. A batch operation must load the plugin UI only once.
- Confirm
onSubmitcontinues standard approval after plugin processing and returns the correct succeeded and failed UUIDs. - Open the same approval as another user and confirm that the flow row extra result is consistent.
- Bypass the plugin UI and submit approval directly; confirm backend
validateblocks the operation when plugin processing is incomplete. - Disable the plugin and confirm standard approval is restored and the extra column is hidden. Re-enable the same Provider and confirm existing rules regain the plugin capability.
After these checks pass, the plugin has implemented the complete rule configuration, operation takeover, backend validation, and flow display path.