Skip to main content

Approval Process Handler

Requirements

Supported Environment

  • Private deployment
  • Web

ONES Version

7.x

Dependency Versions

package.json
{
"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 approve and reject can 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 onSubmit by calling internal approval APIs.

How It Works

The approval process handler contains three frontend slots and one backend validation function:

Integration PointNamePurpose
Rule settings slotones:approval:rule-plugin-settingsConfigure intercepted actions and plugin settings in More Settings
Operation takeover slotones:approval:operation-takeoverRender plugin UI when the user clicks Approve or Reject
Flow row extra slotones:approval:flow-row-extraRender plugin results in the last approval flow column
Backend functionvalidateVerify plugin business completion before ONES writes the approval comment

The end-to-end flow is:

  1. An administrator selects a plugin in an approval rule and saves enabledActions and settings through the rule settings slot.
  2. 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.
  3. When an approver clicks an intercepted action, ONES loads the operation takeover slot instead of the standard comment popover.
  4. The plugin completes and persists its own business result, then calls onSubmit({ comment }).
  5. onSubmit continues the standard ONES approval request. Before writing the approval comment, ONES calls the Provider's validate.
  6. If validation passes, ONES writes the comment and advances the flow. Otherwise, the current item is blocked.
  7. When approval detail is opened, ONES loads the flow row extra slot for each visible row, and the plugin queries and renders its result.
info

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.

note

The current Plugin 1.0 CLI does not generate feature extension configuration automatically. Add the following content to config/plugin.yaml manually.

config/plugin.yaml
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

FieldRequirement
service.app_idExactly eight alphanumeric characters; use the production App ID created by ones create
providerExactly eight alphanumeric characters; do not change it after publishing
funcs[].nameMust be validate
funcs[].urlMust be the plugin function name validate, not /approval/validate
slots[].entryUrlMust point to modules/<module-id>/index.html in the package
displayNameNon-empty after trimming, at most 80 characters
supportedActionsAt least one unique value; only approve and reject are accepted
flowColumnWidthOptional, 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.

info

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:

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:

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

FieldDescription
modeRule entry: create, edit, or copy
supportedActionsMaximum actions declared by the Provider
value.enabledActionsActions enabled by the current rule; must be a subset of supportedActions
value.settingsPlugin business settings for the current rule
onChangeSends the complete enabledActions and settings value to the host
onInitRegisters 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

FieldDescription
actionCurrent action, either approve or reject
commentRequiredWhether the current rule requires a comment; the plugin controls its UI and the host validates again
settingsPlugin rule settings frozen when the approval was created
approvalsSingle or batch approval targets; its length is 1 for a single approval
onSubmitSubmits standard approval through the host and returns succeeded and failed approval UUIDs
onCancelCancels 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:

  1. Validate plugin UI input.
  2. Complete and persist plugin business processing for the targets in approvals.
  3. Ensure the plugin backend can expose the completed state to validate.
  4. Call onSubmit({ comment }) once for the whole batch.
  5. 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:

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:

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:

web/src/approval/approval-operation-takeover.css
.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.length is 1.
  • Batch approval loads the slot once, with all applicable targets in approvals.
  • Call onSubmit only once after plugin processing for the whole batch is complete.
  • onSubmit reuses standard ONES parameter validation, duplicate submission prevention, messages, batch result handling, and detail refresh.
  • If all items fail, onSubmit rejects. It resolves for full or partial success.
  • Handle batch results using succeededApprovalUUIDs and failedApprovalUUIDs.
  • onCancel closes 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

FieldDescription
settingsPlugin rule settings frozen when the approval was created
approvalUUIDCurrent approval UUID
row.rowTypeCurrent flow row type
row.nodeUUIDCurrent node UUID; available only for some row types
row.opinionUUIDCurrent opinion UUID; available for real approval opinion rows

Demo: Flow Row Extra

Create web/src/approval/approval-flow-row-extra.tsx:

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:

rowTypeAvailable Identity
submitapprovalUUID
opinionapprovalUUID, nodeUUID, opinionUUID
pending-usersapprovalUUID, nodeUUID
ccapprovalUUID, nodeUUID
executeapprovalUUID, nodeUUID
revokeapprovalUUID

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:

web/src/modules/approval-rule-settings-v1/index.tsx
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:

backend/src/index.ts
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:

FieldDescription
team_uuidCurrent team UUID
user_uuidCurrent approval operator UUID
rule.rule_uuidApproval rule UUID used by the approval
rule.rule_versionRule version frozen when the approval was created
rule.settingsPlugin rule settings frozen when the approval was created
approval.approval_uuidCurrent approval UUID
approval.node_uuidCurrent pending node UUID
approval.opinion_uuidPending opinion UUID for the current operator
actionCurrent action: approve or reject
commentComment 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 localStorage or current browser state.
  • Use approval_uuid + opinion_uuid + action as 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

ScenarioONES Behavior
No approval Provider is installedUse the standard approval flow
Provider is disabled or uninstalledPreserve historical rule binding, restore standard approve/reject, and hide the extra column
Provider is enabled but its configuration is invalidBlock intercepted approve or reject
Rule settings slot fails to loadDo not allow a new plugin rule configuration to be saved
Operation takeover slot fails to load or runBlock approval and do not fall back to the standard comment popover
validate returns an error, fails, or times outBlock the current approval item
Flow row slot failsDegrade 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_id is eight alphanumeric characters without a dev_ prefix.
  • service.mode matches the plugin installation mode.
  • provider is a stable eight-character alphanumeric value.
  • All three entryUrl values use modules/<module-id>/index.html.
  • funcs[].url is validate.

Verify the Integration

  1. Upload, install, and enable the plugin. Select its Provider in the approval rule's More Settings step and publish the rule.
  2. Verify single Approve, single Reject, and batch approval. A batch operation must load the plugin UI only once.
  3. Confirm onSubmit continues standard approval after plugin processing and returns the correct succeeded and failed UUIDs.
  4. Open the same approval as another user and confirm that the flow row extra result is consistent.
  5. Bypass the plugin UI and submit approval directly; confirm backend validate blocks the operation when plugin processing is incomplete.
  6. 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.