From 065bc7a6fc66dc3019db08df4ce9e57d340acc40 Mon Sep 17 00:00:00 2001 From: Juan Mrad Date: Thu, 7 May 2026 14:23:40 -0500 Subject: [PATCH] Add parameterized actions to provide runtime values when executing (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add typed Action parameter authoring (PR 1 for #377) Introduce a typed parameter spec for Actions so moderators can be prompted for runtime values when executing an action (e.g. "Ban for N days"). This PR is the authoring and configuration part definition, validation, persistence, and the create/edit UI from the actions form only. There will be a follow up prs to surface on the actions page and execution as well as handling of audit log. Added new Actions model and added new fields to the existing actions model. * Regenerate GraphQL types Sync the committed `generated.ts` files with the schema after the docstring trim that landed in 590c506 was applied without re-running codegen. Fixes the `check_generated_graphql` CI job on PR #400. * fix tests * [377] Wire Action parameters through execution + persist to ClickHouse audit log (#408) * Wire Action parameters through execution + persist to ClickHouse audit log (PR 2 for #377) Builds on PR 1 (typed parameter authoring, #400) by routing moderator-supplied parameter values from each run UI through the publisher into the action webhook payload, adding a separate "moderator note" field on every execution path, and recording both to the `analytics.ACTION_EXECUTIONS` audit log. Added validation for the parameters on the action page and the actions form. Added form on the actioning with modal to add details on action to be taken and ability to edit those details. * Fix ClickHouse action_executions migration syntax ClickHouse columns are non-nullable by default; the `NOT NULL` keyword isn't valid in `ADD COLUMN` and was rejected by the migrator. Drop it and rely on the explicit `DEFAULT '{}'` for back-compat. * Gate parameterized actions behind the modal in MRT v2 + Investigation Two paths were previously skipping the parameter prompt and silently publishing actions with empty values: 1. MRT v2 related-action buttons (e.g. Associated User panel, thread related actions, content view related actions) called the enqueue callback as soon as a policy was picked, with no chance to collect parameter values. 2. The Investigation tool's `ItemAction` rendered all parameter inputs inline below the action selector, which got visually overwhelming for actions with several fields and didn't match the modal-based pattern used elsewhere. Fix: - New `useEnqueueActionGate` hook wraps `onEnqueueActions` at the top of `ManualReviewJobReviewImpl`. Items whose action declares `parameters` are queued and routed through `ActionParametersModal` one action at a time; multiple targets enqueued for the same action in a single batch share one prompt. Items without parameters (or that already carry a `customMrtApiParamDecisionPayload` from another flow) pass straight through. - `ItemAction.tsx` now opens `ActionParametersModal` immediately when a parameterized action is selected and shows a small "Edit" affordance for re-opening it. Cancelling the modal removes the staged action so required values can never be skipped. * Add Edit affordance for parameterized actions in Other Actions panel The Other Actions panel had no way to revise parameter values once an action was enqueued — moderators had to remove the action and re-add it just to fix a typo. Add a small pencil button next to each entry that declares parameters; clicking it reopens `ActionParametersModal` in edit mode, pre-filled with the entry's current payload, and only updates that one entry on save. The edit path is exposed via a new `editParameters` method on the shared `useEnqueueActionGate` hook so the create and edit flows reuse the same modal queue and only one modal is ever rendered at a time. --- .../src/components/ActionParameterInputs.tsx | 297 +++++++ .../src/components/ActionParametersModal.tsx | 115 +++ client/src/components/ItemAction.tsx | 180 +++- client/src/graphql/generated.ts | 436 +++++---- .../webpages/dashboard/actions/ActionForm.tsx | 44 +- .../actions/ActionParametersEditor.tsx | 825 ++++++++++++++++++ .../bulk_actioning/BulkActioningDashboard.tsx | 111 ++- .../CustomMrtApiParamsSection.tsx | 113 --- .../ManualReviewJobReview.tsx | 410 ++++++--- ...ualReviewJobEnqueuedRelatedActionEntry.tsx | 28 +- .../ManualReviewJobEnqueuedRelatedActions.tsx | 16 +- .../v2/useEnqueueActionGate.tsx | 189 ++++ ...d-action-execution-parameters-and-note.sql | 24 + server/graphql/datasources/ActionApi.test.ts | 182 ++++ server/graphql/datasources/ActionApi.ts | 158 ++-- server/graphql/generated.ts | 162 ++++ server/graphql/modules/action.ts | 150 +++- server/routes/action/ActionRoutes.ts | 30 +- server/routes/action/submitAction.test.ts | 187 ++++ server/routes/action/submitAction.ts | 32 +- server/rule_engine/ActionPublisher.test.ts | 228 ++++- server/rule_engine/ActionPublisher.ts | 41 +- .../ActionExecutionLogger.test.ts | 117 +++ .../analyticsLoggers/ActionExecutionLogger.ts | 16 +- .../services/moderationConfigService/index.ts | 15 + .../moderationConfigService.test.ts | 169 +++- .../moderationConfigService.ts | 32 +- .../modules/ActionOperations.ts | 22 + .../modules/actionParameterValueValidation.ts | 151 ++++ .../actionParametersValidation.test.ts | 479 ++++++++++ .../modules/actionParametersValidation.ts | 398 +++++++++ .../modules/actorNoteValidation.ts | 15 + .../dataWarehouse/IDataWarehouseAnalytics.ts | 2 + .../storage/dataWarehouse/warehouseSchema.ts | 12 + 34 files changed, 4869 insertions(+), 517 deletions(-) create mode 100644 client/src/components/ActionParameterInputs.tsx create mode 100644 client/src/components/ActionParametersModal.tsx create mode 100644 client/src/webpages/dashboard/actions/ActionParametersEditor.tsx delete mode 100644 client/src/webpages/dashboard/mrt/manual_review_job/CustomMrtApiParamsSection.tsx create mode 100644 client/src/webpages/dashboard/mrt/manual_review_job/v2/useEnqueueActionGate.tsx create mode 100644 db/src/scripts/clickhouse/2026.05.02T00.07.03.add-action-execution-parameters-and-note.sql create mode 100644 server/graphql/datasources/ActionApi.test.ts create mode 100644 server/routes/action/submitAction.test.ts create mode 100644 server/services/analyticsLoggers/ActionExecutionLogger.test.ts create mode 100644 server/services/moderationConfigService/modules/actionParameterValueValidation.ts create mode 100644 server/services/moderationConfigService/modules/actionParametersValidation.test.ts create mode 100644 server/services/moderationConfigService/modules/actionParametersValidation.ts create mode 100644 server/services/moderationConfigService/modules/actorNoteValidation.ts diff --git a/client/src/components/ActionParameterInputs.tsx b/client/src/components/ActionParameterInputs.tsx new file mode 100644 index 0000000..49da7a2 --- /dev/null +++ b/client/src/components/ActionParameterInputs.tsx @@ -0,0 +1,297 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Input, InputNumber, Select, Switch, Tooltip } from 'antd'; +import { useMemo } from 'react'; + +import { + type GQLActionParameter, + GQLActionParameterType, +} from '../graphql/generated'; + +const { Option } = Select; + +export type ActionParameterValues = Readonly>; + +type Props = { + parameters: ReadonlyArray; + values: ActionParameterValues; + onChange: (next: ActionParameterValues) => void; + /** Optional id prefix so multiple instances on a page get unique input ids. */ + idPrefix?: string; + disabled?: boolean; +}; + +/** + * Renders one input widget per `ActionParameter`, using the appropriate + * Ant Design control for each parameter `type`. Designed as the single source + * of truth for moderator-facing parameter entry across the dashboard + * (ItemAction modal, BulkActioningDashboard, MRT review). + * + * The component is fully controlled: the parent owns the `values` map and + * applies the supplied `onChange` callback to integrate edits. + */ +export default function ActionParameterInputs({ + parameters, + values, + onChange, + idPrefix, + disabled, +}: Props) { + const setValue = (name: string, value: unknown) => { + if (value === undefined) { + // Drop the key via destructuring rather than `delete` to satisfy the + // `no-dynamic-delete` rule, and avoid mutating the prop. + const { [name]: _omitted, ...rest } = values; + onChange(rest); + return; + } + onChange({ ...values, [name]: value }); + }; + + if (parameters.length === 0) return null; + + return ( +
+ {parameters.map((param) => ( + setValue(param.name, next)} + /> + ))} +
+ ); +} + +function ParameterInput({ + param, + value, + onChange, + idPrefix, + disabled, +}: { + param: GQLActionParameter; + value: unknown; + onChange: (next: unknown) => void; + idPrefix?: string; + disabled?: boolean; +}) { + const id = `${idPrefix ?? 'param'}-${param.name}`; + const constraints = constraintHint(param); + const labelTooltip = [param.description, constraints] + .filter((s): s is string => Boolean(s)) + .join(' — '); + + const label = ( + + ); + const description = param.description ? ( +
{param.description}
+ ) : null; + const constraintHintBelow = constraints ? ( +
{constraints}
+ ) : null; + + const inputElement = (() => { + switch (param.type) { + case GQLActionParameterType.String: + return ( + + onChange(e.target.value === '' ? undefined : e.target.value) + } + /> + ); + case GQLActionParameterType.Number: + return ( + onChange(next ?? undefined)} + style={{ width: '100%' }} + /> + ); + case GQLActionParameterType.Boolean: + // Wrap in a `self-start`/`inline-flex` span so the parent + // `flex flex-col` doesn't stretch the Switch button to full width. + return ( + + onChange(checked)} + /> + + ); + case GQLActionParameterType.Select: { + const options = param.options ?? []; + return ( + + ); + } + case GQLActionParameterType.Multiselect: { + const options = param.options ?? []; + return ( + + id={id} + disabled={disabled} + mode="multiple" + style={{ width: '100%' }} + value={ + Array.isArray(value) + ? value.filter((v): v is string => typeof v === 'string') + : [] + } + onChange={(next) => onChange(next.length === 0 ? undefined : next)} + allowClear + > + {options.map((opt) => ( + + ))} + + ); + } + default: + return null; + } + })(); + + return ( +
+ {label} + {description} + {inputElement} + {constraintHintBelow} +
+ ); +} + +/** + * Renders a short human-readable summary of the validation constraints on a + * parameter, e.g. "Between 1 and 365" for a NUMBER with min/max, or + * "Up to 500 characters" for a STRING with maxLength. Returns `undefined` + * when there's nothing useful to surface (e.g. BOOLEAN, or unconstrained + * STRING). Also used as the label tooltip body alongside the description. + */ +function constraintHint(param: GQLActionParameter): string | undefined { + switch (param.type) { + case GQLActionParameterType.String: { + if (param.maxLength != null) { + return `Up to ${param.maxLength} characters`; + } + return undefined; + } + case GQLActionParameterType.Number: { + const { min, max } = param; + if (min != null && max != null) return `Between ${min} and ${max}`; + if (min != null) return `At least ${min}`; + if (max != null) return `At most ${max}`; + return undefined; + } + case GQLActionParameterType.Select: + return 'Choose one'; + case GQLActionParameterType.Multiselect: + return 'Choose one or more'; + case GQLActionParameterType.Boolean: + default: + return undefined; + } +} + +/** + * Returns a list of parameter `displayName`s that are required but missing or + * empty in `values`. Empty list means the values map is submittable. Used by + * callers to gate the submit button and surface the missing fields in a + * disabled-button tooltip. + */ +export function findMissingRequiredParameters( + parameters: ReadonlyArray, + values: ActionParameterValues, +): string[] { + const missing: string[] = []; + for (const param of parameters) { + if (!param.required) continue; + const present = Object.prototype.hasOwnProperty.call(values, param.name); + const value = values[param.name]; + const isEmpty = + !present || + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && value.length === 0); + // A `defaultValue` on the spec satisfies "required" since the server will + // backfill on publish — keeps the UX consistent with server behavior. + if (isEmpty && (param.defaultValue === undefined || param.defaultValue === null)) { + missing.push(param.displayName); + } + } + return missing; +} + +/** + * Memoized helper: takes a `Record` map and a + * single action-id update, returns the next map with that one action's values + * replaced. Tiny but used in three call sites. + */ +export function useUpdateActionValues( + setMap: ( + next: Readonly>, + ) => void, + map: Readonly>, +) { + return useMemo( + () => + (actionId: string, values: ActionParameterValues) => { + // Drop the entry entirely when the values map is empty, so the GQL + // input doesn't carry meaningless `{}` entries that confuse log + // readers. + if (Object.keys(values).length === 0) { + if (!(actionId in map)) return; + const { [actionId]: _omitted, ...rest } = map; + setMap(rest); + return; + } + setMap({ ...map, [actionId]: values }); + }, + [map, setMap], + ); +} diff --git a/client/src/components/ActionParametersModal.tsx b/client/src/components/ActionParametersModal.tsx new file mode 100644 index 0000000..13b29c1 --- /dev/null +++ b/client/src/components/ActionParametersModal.tsx @@ -0,0 +1,115 @@ +import { Tooltip } from 'antd'; +import { useEffect, useState } from 'react'; + +import CoopModal from '@/webpages/dashboard/components/CoopModal'; +import { type CoopModalFooterButtonProps } from '@/webpages/dashboard/components/CoopModalFooter'; +import { type GQLActionParameter } from '@/graphql/generated'; + +import ActionParameterInputs, { + type ActionParameterValues, + findMissingRequiredParameters, +} from './ActionParameterInputs'; + +type Props = { + open: boolean; + /** Display name shown in the modal title (e.g. action name). */ + actionName: string; + parameters: ReadonlyArray; + /** + * Values to pre-fill the form with. In `create` mode this is typically the + * spec's defaults; in `edit` mode it's whatever the moderator previously + * saved. The modal owns its own working copy and only reports it back via + * `onSave`, so cancelling never mutates the parent. + */ + initialValues: ActionParameterValues; + /** `create` adds a new selection; `edit` updates an existing one. */ + mode: 'create' | 'edit'; + onSave: (values: ActionParameterValues) => void; + onCancel: () => void; +}; + +/** + * Modal wrapper around `ActionParameterInputs` for picking parameter values + * before an action is committed to the selection. Used in screens where + * multiple parameterized actions can be selected and inline editing would + * become visually crowded (e.g. MRT review). + */ +export default function ActionParametersModal({ + open, + actionName, + parameters, + initialValues, + mode, + onSave, + onCancel, +}: Props) { + const [values, setValues] = useState(initialValues); + + // Re-seed the working copy whenever the modal is (re-)opened so a stale + // edit from a previous open doesn't leak into a new session. + useEffect(() => { + if (open) { + setValues(initialValues); + } + }, [open, initialValues]); + + const missing = findMissingRequiredParameters(parameters, values); + const canSave = missing.length === 0; + const saveTitle = mode === 'create' ? 'Add' : 'Save'; + + const footer: CoopModalFooterButtonProps[] = [ + { + title: 'Cancel', + type: 'secondary', + onClick: onCancel, + }, + { + title: saveTitle, + type: 'primary', + disabled: !canSave, + onClick: () => onSave(values), + }, + ]; + + return ( + +
+ + {!canSave && ( + +
+ Fill in {missing.length === 1 ? 'the required field' : 'all required fields'} to continue. +
+
+ )} +
+
+ ); +} + +/** + * Builds an initial `ActionParameterValues` map from a parameter spec by + * copying each parameter's `defaultValue` (when set). Used to seed the modal + * the first time an action is selected. + */ +export function defaultValuesForParameters( + parameters: ReadonlyArray, +): ActionParameterValues { + const out: Record = {}; + for (const param of parameters) { + if (param.defaultValue !== undefined && param.defaultValue !== null) { + out[param.name] = param.defaultValue; + } + } + return out; +} diff --git a/client/src/components/ItemAction.tsx b/client/src/components/ItemAction.tsx index 126eeb6..0b6b5cf 100644 --- a/client/src/components/ItemAction.tsx +++ b/client/src/components/ItemAction.tsx @@ -1,11 +1,18 @@ +import ActionParametersModal, { + defaultValuesForParameters, +} from '@/components/ActionParametersModal'; +import { type ActionParameterValues } from '@/components/ActionParameterInputs'; +import { type JsonObject } from 'type-fest'; import { + type GQLActionParameter, namedOperations, useGQLBulkActionExecutionMutation, useGQLBulkActionsFormDataQuery, } from '@/graphql/generated'; import { stripTypename } from '@/graphql/inputHelpers'; import { ItemIdentifier } from '@roostorg/types'; -import { Select } from 'antd'; +import Pencil from '@/icons/lni/Education/pencil.svg?react'; +import { Button, Input, Select } from 'antd'; import orderBy from 'lodash/orderBy'; import { useCallback, useMemo, useState } from 'react'; @@ -16,6 +23,16 @@ import PolicyDropdown from '@/webpages/dashboard/components/PolicyDropdown'; const { Option } = Select; +type EligibleAction = { + id: string; + name: string; + parameters: ReadonlyArray; +}; + +type ParamsModalState = + | { open: false } + | { open: true; mode: 'create' | 'edit'; actionId: string }; + export default function ItemAction(props: { itemIdentifier: ItemIdentifier; title?: string; @@ -50,14 +67,90 @@ export default function ItemAction(props: { const [selectedActionIds, setSelectedActionIds] = useState([]); const [showModal, setShowModal] = useState(false); const [modalBody, setModalBody] = useState(''); + const [parametersByActionId, setParametersByActionId] = useState< + Record + >({}); + const [paramsModal, setParamsModal] = useState({ + open: false, + }); + const [moderatorNote, setModeratorNote] = useState(''); + + const eligibleActions: EligibleAction[] = (queryData?.myOrg?.actions ?? []) + .filter((it) => it.itemTypes.map((t) => t.id).includes(itemIdentifier.typeId)) + .map((it) => ({ + id: it.id, + name: it.name, + parameters: ('parameters' in it ? it.parameters : []) ?? [], + })); + + const eligibleActionsById = useMemo( + () => new Map(eligibleActions.map((a) => [a.id, a])), + [eligibleActions], + ); - const eligibleActions = (queryData?.myOrg?.actions ?? []).filter((it) => - it.itemTypes.map((it) => it.id).includes(itemIdentifier.typeId), + const selectedParameterizedActions = useMemo( + () => + selectedActionIds + .map((id) => eligibleActionsById.get(id)) + .filter( + (a): a is EligibleAction => a != null && a.parameters.length > 0, + ), + [selectedActionIds, eligibleActionsById], ); const selectOnChange = useCallback( - (actionIds: string[]) => setSelectedActionIds(actionIds), - [], + (actionIds: string[]) => { + const previous = new Set(selectedActionIds); + const added = actionIds.find((id) => !previous.has(id)); + + const addedAction = added ? eligibleActionsById.get(added) : undefined; + if (addedAction && addedAction.parameters.length > 0) { + // Stage the selection but gate the actual commit behind the modal so + // a moderator can never publish a parameterized action without + // filling in required values. Cancel removes the staged id. + setSelectedActionIds(actionIds); + setParamsModal({ + open: true, + mode: 'create', + actionId: addedAction.id, + }); + return; + } + + setSelectedActionIds(actionIds); + // Drop param payloads for any actions that were just deselected so the + // submitted input doesn't carry stale values. + const next = new Set(actionIds); + setParametersByActionId((prev) => { + const out: Record = {}; + for (const [id, values] of Object.entries(prev)) { + if (next.has(id)) out[id] = values; + } + return out; + }); + }, + [selectedActionIds, eligibleActionsById], + ); + + const onParamsModalCancel = useCallback(() => { + if (paramsModal.open && paramsModal.mode === 'create') { + setSelectedActionIds((ids) => + ids.filter((id) => id !== paramsModal.actionId), + ); + } + setParamsModal({ open: false }); + }, [paramsModal]); + + const onParamsModalSave = useCallback( + (values: ActionParameterValues) => { + if (!paramsModal.open) return; + setParametersByActionId((prev) => ({ + ...prev, + [paramsModal.actionId]: values, + })); + setParamsModal({ open: false }); + }, + [paramsModal], ); const selectDropdownRender = useCallback( @@ -107,6 +200,18 @@ export default function ItemAction(props: { actionIds: selectedActionIds, itemIds: [itemIdentifier.id], policyIds: selectedPolicyIds, + // Drop empty per-action entries so the input doesn't carry + // meaningless `{}` payloads. GQL `JSONObject` constrains values + // to `JsonValue`; our per-action map's inner values are + // `unknown` because each parameter type produces a different + // concrete value. They're all JSON-serializable in practice + // (string, number, boolean, string[]) and the server + // re-validates. Cast through unknown to satisfy the input type. + parameters: + Object.keys(parametersByActionId).length > 0 + ? (parametersByActionId as unknown as JsonObject) + : undefined, + note: moderatorNote.trim() === '' ? undefined : moderatorNote.trim(), }, }, }), @@ -116,6 +221,8 @@ export default function ItemAction(props: { itemIdentifier.typeId, selectedActionIds, selectedPolicyIds, + parametersByActionId, + moderatorNote, ], ); @@ -125,6 +232,10 @@ export default function ItemAction(props: { return null; } + const activeParamsAction = paramsModal.open + ? eligibleActionsById.get(paramsModal.actionId) + : undefined; + return (
@@ -140,6 +251,7 @@ export default function ItemAction(props: { placeholder="Select action" dropdownMatchSelectWidth={false} filterOption={selectFilterByLabelOption} + value={selectedActionIds} onChange={selectOnChange} dropdownRender={selectDropdownRender} > @@ -173,6 +285,64 @@ export default function ItemAction(props: { disabled={selectedActionIds.length === 0} />
+ {selectedParameterizedActions.length > 0 && ( +
+ {selectedParameterizedActions.map((action) => ( +
+ {action.name} details: + +
+ ))} +
+ )} + {selectedActionIds.length > 0 && ( +
+ + setModeratorNote(e.target.value)} + /> +
+ )} + {paramsModal.open && activeParamsAction && ( + + )} {modalBody} diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 0cde78c..b437651 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -76,6 +76,7 @@ export type GQLActionBase = { readonly itemTypes: ReadonlyArray; readonly name: Scalars['String']['output']; readonly orgId: Scalars['String']['output']; + readonly parameters: ReadonlyArray; readonly penalty: GQLUserPenaltySeverity; }; @@ -100,6 +101,64 @@ export type GQLActionNameExistsError = GQLError & { readonly type: ReadonlyArray; }; +/** + * Definition of a single runtime parameter on an action. The moderator is + * prompted for a value at execution time; the value is included in the + * webhook payload under the parameter's `name`. + */ +export type GQLActionParameter = { + readonly __typename: 'ActionParameter'; + /** Pre-filled value shown to the moderator. Shape matches `type`. */ + readonly defaultValue?: Maybe; + readonly description?: Maybe; + readonly displayName: Scalars['String']['output']; + /** NUMBER only: inclusive maximum. */ + readonly max?: Maybe; + /** STRING only: inclusive maximum length in characters. */ + readonly maxLength?: Maybe; + /** NUMBER only: inclusive minimum. */ + readonly min?: Maybe; + /** Key under which the value is sent in the webhook payload. */ + readonly name: Scalars['String']['output']; + readonly options?: Maybe>; + readonly required: Scalars['Boolean']['output']; + readonly type: GQLActionParameterType; +}; + +export type GQLActionParameterInput = { + readonly defaultValue?: InputMaybe; + readonly description?: InputMaybe; + readonly displayName: Scalars['String']['input']; + readonly max?: InputMaybe; + readonly maxLength?: InputMaybe; + readonly min?: InputMaybe; + readonly name: Scalars['String']['input']; + readonly options?: InputMaybe>; + readonly required: Scalars['Boolean']['input']; + readonly type: GQLActionParameterType; +}; + +export type GQLActionParameterOption = { + readonly __typename: 'ActionParameterOption'; + readonly label: Scalars['String']['output']; + readonly value: Scalars['String']['output']; +}; + +export type GQLActionParameterOptionInput = { + readonly label: Scalars['String']['input']; + readonly value: Scalars['String']['input']; +}; + +export const GQLActionParameterType = { + Boolean: 'BOOLEAN', + Multiselect: 'MULTISELECT', + Number: 'NUMBER', + Select: 'SELECT', + String: 'STRING', +} as const; + +export type GQLActionParameterType = + (typeof GQLActionParameterType)[keyof typeof GQLActionParameterType]; export const GQLActionSource = { AutomatedRule: 'AUTOMATED_RULE', ManualActionRun: 'MANUAL_ACTION_RUN', @@ -654,6 +713,7 @@ export type GQLCreateActionInput = { readonly description?: InputMaybe; readonly itemTypeIds: ReadonlyArray; readonly name: Scalars['String']['input']; + readonly parameters?: InputMaybe>; }; export type GQLCreateBacktestInput = { @@ -817,12 +877,19 @@ export type GQLCustomAction = GQLActionBase & { readonly callbackUrl: Scalars['String']['output']; readonly callbackUrlBody?: Maybe; readonly callbackUrlHeaders?: Maybe; + /** + * Deprecated alias for `parameters` retained for back-compat with the + * initial MRT-only parameter implementation. New consumers should read + * `parameters` instead. + * @deprecated Use `parameters` instead. + */ readonly customMrtApiParams: ReadonlyArray>; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; readonly name: Scalars['String']['output']; readonly orgId: Scalars['String']['output']; + readonly parameters: ReadonlyArray; readonly penalty: GQLUserPenaltySeverity; }; @@ -1051,6 +1118,7 @@ export type GQLEnqueueAuthorToMrtAction = GQLActionBase & { readonly itemTypes: ReadonlyArray; readonly name: Scalars['String']['output']; readonly orgId: Scalars['String']['output']; + readonly parameters: ReadonlyArray; readonly penalty: GQLUserPenaltySeverity; }; @@ -1062,6 +1130,7 @@ export type GQLEnqueueToMrtAction = GQLActionBase & { readonly itemTypes: ReadonlyArray; readonly name: Scalars['String']['output']; readonly orgId: Scalars['String']['output']; + readonly parameters: ReadonlyArray; readonly penalty: GQLUserPenaltySeverity; }; @@ -1073,6 +1142,7 @@ export type GQLEnqueueToNcmecAction = GQLActionBase & { readonly itemTypes: ReadonlyArray; readonly name: Scalars['String']['output']; readonly orgId: Scalars['String']['output']; + readonly parameters: ReadonlyArray; readonly penalty: GQLUserPenaltySeverity; }; @@ -1151,6 +1221,19 @@ export type GQLExecuteBulkActionInput = { readonly actionIds: ReadonlyArray; readonly itemIds: ReadonlyArray; readonly itemTypeId: Scalars['String']['input']; + /** + * Optional moderator-authored note explaining why this action was taken. + * Sent to the action's webhook as `actorNote` and persisted to the action + * execution audit log. + */ + readonly note?: InputMaybe; + /** + * Optional map of `actionId` -> `{ paramName: value }` carrying + * moderator-supplied runtime parameter values. Each map is validated against + * the action's parameter spec server-side before publish; invalid values + * reject the entire request. + */ + readonly parameters?: InputMaybe; readonly policyIds: ReadonlyArray; }; @@ -4407,6 +4490,8 @@ export type GQLUpdateActionInput = { readonly id: Scalars['ID']['input']; readonly itemTypeIds?: InputMaybe>; readonly name?: InputMaybe; + /** Replace the parameter list (`[]` clears it). Omit to leave unchanged. */ + readonly parameters?: InputMaybe>; }; export type GQLUpdateContentItemTypeInput = { @@ -5328,6 +5413,23 @@ export type GQLCustomActionFragmentFragment = { | { readonly __typename: 'ThreadItemType'; readonly id: string } | { readonly __typename: 'UserItemType'; readonly id: string } >; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; }; export type GQLActionQueryVariables = Exact<{ @@ -5350,6 +5452,23 @@ export type GQLActionQuery = { | { readonly __typename: 'ThreadItemType'; readonly id: string } | { readonly __typename: 'UserItemType'; readonly id: string } >; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; } | { readonly __typename: 'EnqueueAuthorToMrtAction' } | { readonly __typename: 'EnqueueToMrtAction' } @@ -5415,6 +5534,23 @@ export type GQLCreateActionMutation = { | { readonly __typename: 'ThreadItemType'; readonly id: string } | { readonly __typename: 'UserItemType'; readonly id: string } >; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; }; }; }; @@ -5447,6 +5583,23 @@ export type GQLUpdateActionMutation = { | { readonly __typename: 'ThreadItemType'; readonly id: string } | { readonly __typename: 'UserItemType'; readonly id: string } >; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; }; }; }; @@ -5743,6 +5896,23 @@ export type GQLBulkActionsFormDataQuery = { readonly __typename: 'CustomAction'; readonly id: string; readonly name: string; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; readonly id: string } | { readonly __typename: 'ThreadItemType'; readonly id: string } @@ -5753,6 +5923,23 @@ export type GQLBulkActionsFormDataQuery = { readonly __typename: 'EnqueueAuthorToMrtAction'; readonly id: string; readonly name: string; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; readonly id: string } | { readonly __typename: 'ThreadItemType'; readonly id: string } @@ -5763,6 +5950,23 @@ export type GQLBulkActionsFormDataQuery = { readonly __typename: 'EnqueueToMrtAction'; readonly id: string; readonly name: string; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; readonly id: string } | { readonly __typename: 'ThreadItemType'; readonly id: string } @@ -5773,6 +5977,23 @@ export type GQLBulkActionsFormDataQuery = { readonly __typename: 'EnqueueToNcmecAction'; readonly id: string; readonly name: string; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; readonly id: string } | { readonly __typename: 'ThreadItemType'; readonly id: string } @@ -11664,45 +11885,6 @@ export type GQLSetModeratorSafetySettingsMutation = { } | null; }; -export type GQLActionsWithCustomParamsQueryVariables = Exact<{ - [key: string]: never; -}>; - -export type GQLActionsWithCustomParamsQuery = { - readonly __typename: 'Query'; - readonly myOrg?: { - readonly __typename: 'Org'; - readonly actions: ReadonlyArray< - | { - readonly __typename: 'CustomAction'; - readonly id: string; - readonly name: string; - readonly customMrtApiParams: ReadonlyArray<{ - readonly __typename: 'CustomMrtApiParamSpec'; - readonly name: string; - readonly type: string; - readonly displayName: string; - } | null>; - } - | { - readonly __typename: 'EnqueueAuthorToMrtAction'; - readonly id: string; - readonly name: string; - } - | { - readonly __typename: 'EnqueueToMrtAction'; - readonly id: string; - readonly name: string; - } - | { - readonly __typename: 'EnqueueToNcmecAction'; - readonly id: string; - readonly name: string; - } - >; - } | null; -}; - export type GQLManualReviewJobInfoQueryVariables = Exact<{ jobIds?: InputMaybe< ReadonlyArray | Scalars['ID']['input'] @@ -11926,12 +12108,23 @@ export type GQLManualReviewJobInfoQuery = { readonly name: string; } >; - readonly customMrtApiParams: ReadonlyArray<{ - readonly __typename: 'CustomMrtApiParamSpec'; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; readonly name: string; - readonly type: string; readonly displayName: string; - } | null>; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; } | { readonly __typename: 'EnqueueAuthorToMrtAction'; @@ -24178,6 +24371,21 @@ export const GQLCustomActionFragmentFragmentDoc = gql` callbackUrl callbackUrlHeaders callbackUrlBody + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } } `; export const GQLManualReviewDecisionComponentFieldsFragmentDoc = gql` @@ -28504,6 +28712,21 @@ export const GQLBulkActionsFormDataDocument = gql` ... on ActionBase { id name + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } } ... on CustomAction { itemTypes { @@ -33338,118 +33561,6 @@ export type GQLSetModeratorSafetySettingsMutationOptions = GQLSetModeratorSafetySettingsMutation, GQLSetModeratorSafetySettingsMutationVariables >; -export const GQLActionsWithCustomParamsDocument = gql` - query ActionsWithCustomParams { - myOrg { - actions { - ... on ActionBase { - id - name - } - ... on CustomAction { - id - name - customMrtApiParams { - name - type - displayName - } - } - } - } - } -`; - -/** - * __useGQLActionsWithCustomParamsQuery__ - * - * To run a query within a React component, call `useGQLActionsWithCustomParamsQuery` and pass it any options that fit your needs. - * When your component renders, `useGQLActionsWithCustomParamsQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useGQLActionsWithCustomParamsQuery({ - * variables: { - * }, - * }); - */ -export function useGQLActionsWithCustomParamsQuery( - baseOptions?: Apollo.QueryHookOptions< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useQuery< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables - >(GQLActionsWithCustomParamsDocument, options); -} -export function useGQLActionsWithCustomParamsLazyQuery( - baseOptions?: Apollo.LazyQueryHookOptions< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useLazyQuery< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables - >(GQLActionsWithCustomParamsDocument, options); -} -// @ts-ignore -export function useGQLActionsWithCustomParamsSuspenseQuery( - baseOptions?: Apollo.SuspenseQueryHookOptions< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables - >, -): Apollo.UseSuspenseQueryResult< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables ->; -export function useGQLActionsWithCustomParamsSuspenseQuery( - baseOptions?: - | Apollo.SkipToken - | Apollo.SuspenseQueryHookOptions< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables - >, -): Apollo.UseSuspenseQueryResult< - GQLActionsWithCustomParamsQuery | undefined, - GQLActionsWithCustomParamsQueryVariables ->; -export function useGQLActionsWithCustomParamsSuspenseQuery( - baseOptions?: - | Apollo.SkipToken - | Apollo.SuspenseQueryHookOptions< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables - >, -) { - const options = - baseOptions === Apollo.skipToken - ? baseOptions - : { ...defaultOptions, ...baseOptions }; - return Apollo.useSuspenseQuery< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables - >(GQLActionsWithCustomParamsDocument, options); -} -export type GQLActionsWithCustomParamsQueryHookResult = ReturnType< - typeof useGQLActionsWithCustomParamsQuery ->; -export type GQLActionsWithCustomParamsLazyQueryHookResult = ReturnType< - typeof useGQLActionsWithCustomParamsLazyQuery ->; -export type GQLActionsWithCustomParamsSuspenseQueryHookResult = ReturnType< - typeof useGQLActionsWithCustomParamsSuspenseQuery ->; -export type GQLActionsWithCustomParamsQueryResult = Apollo.QueryResult< - GQLActionsWithCustomParamsQuery, - GQLActionsWithCustomParamsQueryVariables ->; export const GQLManualReviewJobInfoDocument = gql` query ManualReviewJobInfo($jobIds: [ID!]) { myOrg { @@ -33486,10 +33597,20 @@ export const GQLManualReviewJobInfoDocument = gql` name } } - customMrtApiParams { + parameters { name - type displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue } } } @@ -43150,7 +43271,6 @@ export const namedOperations = { getSkipsForRecentDecisions: 'getSkipsForRecentDecisions', GetDecidedJob: 'GetDecidedJob', ManualReviewSafetySettings: 'ManualReviewSafetySettings', - ActionsWithCustomParams: 'ActionsWithCustomParams', ManualReviewJobInfo: 'ManualReviewJobInfo', getRelatedItems: 'getRelatedItems', GetCommentsForJob: 'GetCommentsForJob', diff --git a/client/src/webpages/dashboard/actions/ActionForm.tsx b/client/src/webpages/dashboard/actions/ActionForm.tsx index 29d7ab2..9ace988 100644 --- a/client/src/webpages/dashboard/actions/ActionForm.tsx +++ b/client/src/webpages/dashboard/actions/ActionForm.tsx @@ -24,6 +24,12 @@ import { } from '../../../graphql/generated'; import { userHasPermissions } from '../../../routing/permissions'; import { prettyPrintJsonValue } from '../../../utils/string'; +import ActionParametersEditor, { + type ActionParameterDraft, + fromGraphQLParameters, + toMutationInput, + validateDrafts, +} from './ActionParametersEditor'; const { Option } = Select; @@ -40,6 +46,21 @@ gql` callbackUrl callbackUrlHeaders callbackUrlBody + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } } query Action($id: ID!) { @@ -120,6 +141,9 @@ export default function ActionForm() { const [actionCallbackUrlBody, setActionCallbackUrlBody] = useState< string | undefined >(undefined); + const [actionParameters, setActionParameters] = useState< + ActionParameterDraft[] + >([]); const showModal = () => { setModalVisible(true); @@ -190,6 +214,7 @@ export default function ActionForm() { ? prettyPrintJsonValue(action.callbackUrlBody) : undefined, ); + setActionParameters(fromGraphQLParameters(action.parameters)); }, [action]); if (actionQueryError ?? actionFormQueryError) { @@ -219,6 +244,7 @@ export default function ActionForm() { callbackUrlBody: actionCallbackUrlBody ? JSON.parse(actionCallbackUrlBody) : undefined, + parameters: toMutationInput(actionParameters), }, }, refetchQueries: [namedOperations.Query.Actions], @@ -239,6 +265,7 @@ export default function ActionForm() { callbackUrlBody: actionCallbackUrlBody ? JSON.parse(actionCallbackUrlBody) : undefined, + parameters: toMutationInput(actionParameters), }, }, refetchQueries: [ @@ -416,6 +443,16 @@ export default function ActionForm() { {divider()} {callbackUrlInput} {divider()} + + + {divider()} { @@ -446,6 +484,10 @@ export default function ActionForm() { if (!validateJson(actionCallbackUrlBody)) { return 'Please enter a valid JSON for the callback URL body.'; } + const paramError = validateDrafts(actionParameters); + if (paramError !== null) { + return paramError; + } })()} disabledTooltipPlacement="bottomLeft" onClick={() => { diff --git a/client/src/webpages/dashboard/actions/ActionParametersEditor.tsx b/client/src/webpages/dashboard/actions/ActionParametersEditor.tsx new file mode 100644 index 0000000..b95c049 --- /dev/null +++ b/client/src/webpages/dashboard/actions/ActionParametersEditor.tsx @@ -0,0 +1,825 @@ +import { ChevronsUpDown, Plus, Trash2 } from 'lucide-react'; +import { useId, useMemo } from 'react'; + +import { Button } from '@/coop-ui/Button'; +import { Checkbox } from '@/coop-ui/Checkbox'; +import { Input } from '@/coop-ui/Input'; +import { Label } from '@/coop-ui/Label'; +import { Popover, PopoverContent, PopoverTrigger } from '@/coop-ui/Popover'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/coop-ui/Select'; +import { Switch } from '@/coop-ui/Switch'; +import { cn } from '@/lib/utils'; + +import { + type GQLActionParameterInput, + type GQLActionParameterOptionInput, + GQLActionParameterType, +} from '../../../graphql/generated'; + +// Mirror of the server-side pattern in `actionParametersValidation.ts`. Allows +// snake_case, kebab-case, and dotted namespacing; rejects whitespace, quotes, +// and brackets that would break webhook-payload key access. +const PARAMETER_NAME_PATTERN = /^[a-zA-Z0-9_.\-]+$/; + +// Radix `SelectItem` rejects `value=""`, so we use a sentinel string for the +// "no default" option in BOOLEAN / SELECT default-value pickers. The sentinel +// is never serialized — it's mapped back to `undefined` in the change handler. +const NO_DEFAULT = '__no_default__'; + +export type ActionParameterDraft = { + name: string; + displayName: string; + description?: string; + type: GQLActionParameterType; + required: boolean; + options?: GQLActionParameterOptionInput[]; + min?: number; + max?: number; + maxLength?: number; + // Stored in the parameter's native shape (string / number / boolean / + // string[]); each `type` renders the matching input widget so we never + // need to coerce strings on submit. + defaultValue?: unknown; +}; + +/** + * Repeating editor for an action's runtime parameters. Each row defines one + * parameter spec; the moderator will be prompted for a value at execution + * time. The serialized value (via `toMutationInput`) feeds + * `CreateActionInput.parameters` / `UpdateActionInput.parameters`. + */ +export default function ActionParametersEditor({ + value, + onChange, + disabled, +}: { + value: ActionParameterDraft[]; + onChange: (next: ActionParameterDraft[]) => void; + disabled?: boolean; +}) { + const updateAt = (index: number, patch: Partial) => { + const next = value.slice(); + next[index] = { ...next[index], ...patch }; + onChange(next); + }; + + const removeAt = (index: number) => { + onChange(value.filter((_, i) => i !== index)); + }; + + const addParameter = () => { + onChange([ + ...value, + { + name: '', + displayName: '', + type: GQLActionParameterType.String, + required: false, + }, + ]); + }; + + return ( +
+ {value.map((param, index) => ( + updateAt(index, patch)} + onRemove={() => removeAt(index)} + /> + ))} + +
+ ); +} + +function ParameterRow({ + param, + disabled, + onChange, + onRemove, +}: { + param: ActionParameterDraft; + disabled?: boolean; + onChange: (patch: Partial) => void; + onRemove: () => void; +}) { + const id = useId(); + const isSelectLike = + param.type === GQLActionParameterType.Select || + param.type === GQLActionParameterType.Multiselect; + const isNumber = param.type === GQLActionParameterType.Number; + const isString = param.type === GQLActionParameterType.String; + const hasConstraints = isString || isNumber; + + return ( +
+
+ + onChange({ name: e.target.value })} + /> + + + onChange({ displayName: e.target.value })} + /> + + + + + + onChange({ required })} + /> + + + + + onChange({ + description: e.target.value === '' ? undefined : e.target.value, + }) + } + /> + + {hasConstraints ? ( + + ) : ( + // Keep the second row balanced when no constraints exist for the type. +
+ )} + + + onChange({ defaultValue })} + /> + + + {isSelectLike && ( + + onChange({ options })} + /> + + )} +
+ +
+ +
+
+ ); +} + +function ConstraintsRow({ + id, + param, + isString, + isNumber, + disabled, + onChange, +}: { + id: string; + param: ActionParameterDraft; + isString: boolean; + isNumber: boolean; + disabled?: boolean; + onChange: (patch: Partial) => void; +}) { + if (isString) { + return ( + + onChange({ maxLength })} + /> + + ); + } + if (isNumber) { + return ( + <> + + onChange({ min })} + /> + + + onChange({ max })} + /> + + + ); + } + return null; +} + +function DefaultValueInput({ + id, + param, + disabled, + onChange, +}: { + id: string; + param: ActionParameterDraft; + disabled?: boolean; + onChange: (next: unknown) => void; +}) { + switch (param.type) { + case GQLActionParameterType.String: { + const value = typeof param.defaultValue === 'string' ? param.defaultValue : ''; + return ( + + onChange(e.target.value === '' ? undefined : e.target.value) + } + /> + ); + } + case GQLActionParameterType.Number: { + const value = + typeof param.defaultValue === 'number' ? param.defaultValue : undefined; + return ( + onChange(next)} + /> + ); + } + case GQLActionParameterType.Boolean: { + const dv = param.defaultValue; + const value = dv === true ? 'true' : dv === false ? 'false' : NO_DEFAULT; + return ( + + ); + } + case GQLActionParameterType.Select: { + // Skip half-typed option rows: Radix `SelectItem` rejects `value=""`, + // and an unlabelled item isn't pickable anyway. + const options = (param.options ?? []).filter((opt) => opt.value !== ''); + if (options.length === 0) { + return ; + } + const dv = param.defaultValue; + const value = typeof dv === 'string' ? dv : NO_DEFAULT; + return ( + + ); + } + case GQLActionParameterType.Multiselect: { + // Same filter as SELECT: half-typed option rows aren't pickable and + // would collide on `key={opt.value}`. + const options = (param.options ?? []).filter((opt) => opt.value !== ''); + if (options.length === 0) { + return ; + } + const selected = new Set( + Array.isArray(param.defaultValue) + ? param.defaultValue.filter((v): v is string => typeof v === 'string') + : [], + ); + return ( + + ); + } + default: + return null; + } +} + +function EmptyOptionsHint() { + return ( +

+ Add options below before picking a default. +

+ ); +} + +/** + * Dropdown for picking multiple option values. coop-ui's `Select` is + * single-value (Radix), so we mimic the `SelectTrigger` chrome with a button + * that opens a `Popover` containing a checkbox list. Visually consistent with + * the SELECT default-value picker. + */ +function MultiSelectDropdown({ + id, + options, + selected, + disabled, + onChange, +}: { + id?: string; + options: readonly GQLActionParameterOptionInput[]; + selected: ReadonlySet; + disabled?: boolean; + onChange: (next: string[] | undefined) => void; +}) { + const labelFor = (opt: GQLActionParameterOptionInput) => + opt.label !== '' ? opt.label : opt.value; + const selectedLabels = options + .filter((opt) => selected.has(opt.value)) + .map(labelFor); + const summary = + selectedLabels.length === 0 + ? 'No default' + : selectedLabels.length <= 2 + ? selectedLabels.join(', ') + : `${selectedLabels.slice(0, 2).join(', ')}, +${selectedLabels.length - 2} more`; + + return ( + + + + + +
+ {options.map((opt) => { + const checkboxId = `${id}-${opt.value}`; + const isChecked = selected.has(opt.value); + return ( + + ); + })} +
+
+
+ ); +} + +function NumberInput({ + id, + value, + min, + max, + disabled, + onChange, +}: { + id?: string; + value: number | undefined; + min?: number; + max?: number; + disabled?: boolean; + onChange: (next: number | undefined) => void; +}) { + return ( + { + const raw = e.target.value; + if (raw === '') { + onChange(undefined); + return; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + onChange(undefined); + return; + } + // `` only constrains the spinner UI; + // direct typing can still produce out-of-range values. Clamp here so + // the parent state never sees e.g. a negative `maxLength`. + let clamped = parsed; + if (min !== undefined && clamped < min) clamped = min; + if (max !== undefined && clamped > max) clamped = max; + onChange(clamped); + }} + /> + ); +} + +function Field({ + label, + htmlFor, + children, + className, + align, +}: { + label: string; + htmlFor?: string; + children: React.ReactNode; + className?: string; + // `start` keeps the child at its natural width (e.g. for a `Switch` that + // would otherwise be stretched to the cell width by the parent flex column). + align?: 'start'; +}) { + const alignment = align === 'start' ? 'items-start' : ''; + return ( +
+ + {children} +
+ ); +} + +function OptionsEditor({ + options, + onChange, + disabled, +}: { + options: GQLActionParameterOptionInput[]; + onChange: (next: GQLActionParameterOptionInput[]) => void; + disabled?: boolean; +}) { + const updateAt = ( + index: number, + patch: Partial, + ) => { + const next = options.slice(); + next[index] = { ...next[index], ...patch }; + onChange(next); + }; + return ( +
+ {options.map((opt, index) => ( +
+ updateAt(index, { value: e.target.value })} + /> + updateAt(index, { label: e.target.value })} + /> + +
+ ))} + +
+ ); +} + +/** + * Convert a draft list to the GraphQL input shape. `defaultValue` is already + * stored in its native type (the editor uses typed widgets), so no coercion + * is needed. + */ +export function toMutationInput( + drafts: readonly ActionParameterDraft[], +): GQLActionParameterInput[] { + return drafts.map((draft) => ({ + name: draft.name, + displayName: draft.displayName, + description: draft.description, + type: draft.type, + required: draft.required, + options: draft.options, + min: draft.min, + max: draft.max, + maxLength: draft.maxLength, + // The editor only ever stores string / number / boolean / string[] in + // `defaultValue`; all are valid `JsonValue`s for the GQL `JSON` scalar. + defaultValue: draft.defaultValue as GQLActionParameterInput['defaultValue'], + })); +} + +/** + * Validate the in-progress drafts. Returns `null` when the list is submittable, + * or a human-readable message identifying the first problem so the form can + * surface it via the disabled-button tooltip. + * + * Server still re-validates via AJV on write; this is purely for UX. + */ +export function validateDrafts( + drafts: readonly ActionParameterDraft[], +): string | null { + const seenNames = new Set(); + for (const [index, draft] of drafts.entries()) { + const at = `Parameter #${index + 1}`; + if (!draft.name) return `${at}: name is required.`; + if (!PARAMETER_NAME_PATTERN.test(draft.name)) { + return `${at}: name may only contain letters, digits, _, -, or .`; + } + if (seenNames.has(draft.name)) { + return `${at}: duplicate name "${draft.name}".`; + } + seenNames.add(draft.name); + if (!draft.displayName) return `${at}: display name is required.`; + + if ( + draft.type === GQLActionParameterType.Select || + draft.type === GQLActionParameterType.Multiselect + ) { + if (!draft.options || draft.options.length === 0) { + return `${at}: at least one option is required for ${draft.type}.`; + } + const optionValues = new Set(); + for (const opt of draft.options) { + if (!opt.value || !opt.label) { + return `${at}: each option needs a value and a label.`; + } + if (optionValues.has(opt.value)) { + return `${at}: duplicate option value "${opt.value}".`; + } + optionValues.add(opt.value); + } + } + if ( + draft.type === GQLActionParameterType.Number && + draft.min !== undefined && + draft.max !== undefined && + draft.min > draft.max + ) { + return `${at}: min must be <= max.`; + } + + // Default-value bounds checks. Type-shape is enforced by the typed + // widgets in `DefaultValueInput`, so only range/membership can drift. + const dv = draft.defaultValue; + if (dv !== undefined) { + if (draft.type === GQLActionParameterType.Number && typeof dv === 'number') { + if (draft.min !== undefined && dv < draft.min) { + return `${at}: default below min.`; + } + if (draft.max !== undefined && dv > draft.max) { + return `${at}: default above max.`; + } + } + if ( + draft.type === GQLActionParameterType.String && + typeof dv === 'string' && + draft.maxLength !== undefined && + dv.length > draft.maxLength + ) { + return `${at}: default exceeds maxLength.`; + } + } + } + return null; +} + +/** + * Project an existing parameter (read from the API) into the draft shape used + * by this editor. + */ +export function fromGraphQLParameters( + parameters: ReadonlyArray<{ + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly options?: ReadonlyArray<{ readonly value: string; readonly label: string }> | null; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: unknown; + }>, +): ActionParameterDraft[] { + return parameters.map((p) => ({ + name: p.name, + displayName: p.displayName, + description: p.description ?? undefined, + type: p.type, + required: p.required, + options: p.options + ? p.options.map((o) => ({ value: o.value, label: o.label })) + : undefined, + min: p.min ?? undefined, + max: p.max ?? undefined, + maxLength: p.maxLength ?? undefined, + defaultValue: p.defaultValue ?? undefined, + })); +} + +/** + * Memoize a draft list keyed by the GraphQL response array reference. Avoids + * blowing away in-progress local edits when the action query refetches. + */ +export function useParameterDraftsFromAction< + T extends Parameters[0], +>(parameters: T | undefined): ActionParameterDraft[] | undefined { + return useMemo( + () => (parameters === undefined ? undefined : fromGraphQLParameters(parameters)), + [parameters], + ); +} diff --git a/client/src/webpages/dashboard/bulk_actioning/BulkActioningDashboard.tsx b/client/src/webpages/dashboard/bulk_actioning/BulkActioningDashboard.tsx index 87967d5..df9993d 100644 --- a/client/src/webpages/dashboard/bulk_actioning/BulkActioningDashboard.tsx +++ b/client/src/webpages/dashboard/bulk_actioning/BulkActioningDashboard.tsx @@ -4,7 +4,12 @@ import orderBy from 'lodash/orderBy'; import { useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { Link, useNavigate } from 'react-router-dom'; +import { type JsonObject } from 'type-fest'; +import ActionParameterInputs, { + type ActionParameterValues, + findMissingRequiredParameters, +} from '../../../components/ActionParameterInputs'; import FullScreenLoading from '../../../components/common/FullScreenLoading'; import { selectFilterByLabelOption } from '../components/antDesignUtils'; import CoopButton from '../components/CoopButton'; @@ -40,6 +45,21 @@ gql` ... on ActionBase { id name + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } } ... on CustomAction { itemTypes { @@ -103,6 +123,10 @@ export default function BulkActioningDashboard() { const [selectedActionIds, setSelectedActionIds] = useState([]); const [selectedPolicyIds, setSelectedPolicyIds] = useState([]); const [showSubmissionModal, setShowSubmissionModal] = useState(false); + const [parametersByActionId, setParametersByActionId] = useState< + Record + >({}); + const [moderatorNote, setModeratorNote] = useState(''); const { data: queryData, loading: queryLoading } = useGQLBulkActionsFormDataQuery(); @@ -125,6 +149,8 @@ export default function BulkActioningDashboard() { setInputIds([]); setSelectedActionIds([]); setSelectedPolicyIds([]); + setParametersByActionId({}); + setModeratorNote(''); bulkActionMutationReset(); }; @@ -148,6 +174,14 @@ export default function BulkActioningDashboard() { actionIds: selectedActionIds, itemIds: inputIds, policyIds: selectedPolicyIds ?? [], + // See `ItemAction.tsx` for why this cast is necessary; the runtime + // shape is JSON-serializable but TS can't infer it from the + // discriminated parameter-type union without a heavy generic. + parameters: + Object.keys(parametersByActionId).length > 0 + ? (parametersByActionId as unknown as JsonObject) + : undefined, + note: moderatorNote.trim() === '' ? undefined : moderatorNote.trim(), }, }, }); @@ -206,6 +240,25 @@ export default function BulkActioningDashboard() { ) : []; + // Plain non-memoized derivations: cheap to compute every render and + // unconditional `useMemo` would have to live above the `if (queryLoading)` + // early return, which would force restructuring this whole file. + const selectedActionsWithParams = actions.filter( + (action) => + selectedActionIds.includes(action.id) && action.parameters.length > 0, + ); + const missingRequiredLabels: string[] = (() => { + const out: string[] = []; + for (const action of selectedActionsWithParams) { + const missing = findMissingRequiredParameters( + action.parameters, + parametersByActionId[action.id] ?? {}, + ); + out.push(...missing.map((label) => `"${action.name}" → ${label}`)); + } + return out; + })(); + const actionSelector = ( className="w-56" @@ -386,7 +439,15 @@ export default function BulkActioningDashboard() { disabled={ !inputIds.length || !selectedItemTypeId || - !selectedActionIds.length + !selectedActionIds.length || + missingRequiredLabels.length > 0 + } + disabledTooltipTitle={ + missingRequiredLabels.length > 0 + ? `Fill in required details: ${missingRequiredLabels.join( + ', ', + )}` + : undefined } /> } @@ -413,6 +474,54 @@ export default function BulkActioningDashboard() { } /> {actionSelector} + {selectedActionsWithParams.length > 0 && ( +
+ + {selectedActionsWithParams.map((action) => ( +
+
+ "{action.name}" details +
+ + setParametersByActionId((prev) => ({ + ...prev, + [action.id]: next, + })) + } + idPrefix={`bulk-action-${action.id}`} + /> +
+ ))} +
+ )} + {selectedActionIds.length > 0 && ( +
+ + setModeratorNote(e.target.value)} + className="w-3/4" + /> +
+ )}
, - ) => void; -}) { - const { selectedActionIds, setCustomParamsForAction } = props; - - const { data } = useGQLActionsWithCustomParamsQuery(); - const actions = data?.myOrg?.actions; - if (!actions) { - return null; - } - const actionsWithCustomParams = actions.filter( - (action) => - action.__typename === 'CustomAction' && - action.customMrtApiParams !== undefined && - action.customMrtApiParams.length > 0, - ); - - return ( -
- {actionsWithCustomParams.map((action) => { - if ( - !selectedActionIds.includes(action.id) || - !('customMrtApiParams' in action) - ) { - return null; - } - const customParams = filterNullOrUndefined(action.customMrtApiParams); - return customParams.map((actionParam) => { - if (actionParam.type === 'BOOLEAN') { - return ( -
- - setCustomParamsForAction(action.id, { - [actionParam.name]: checked, - }) - } - /> - -
- ); - } else if (actionParam.type === 'STRING') { - return ( -
- -