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 ( -
- -