From 96125aa6ae250c8412111ae7a38de88fa8b2a8a0 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 12:55:48 -0500 Subject: [PATCH 01/38] Add rubric template utilities, collapsible FieldConfigCard, and store/registry scaffolding - Create rubricTemplate.ts with pure functions for CRUD operations on RubricTemplateSchema (scored, yes/no, dropdown, long_text criterion types) - Extend FieldConfigCard with optional collapsible/accordion behavior using React Aria Disclosure primitives with animated height transitions - Add headerExtra prop to FieldConfigCard for rendering badges in the header - Add setRubricTemplateSchema/getRubricTemplateSchema to ProcessBuilder store - Create rubricCriterionRegistry with type metadata, icons, and labels --- .../rubric/rubricCriterionRegistry.tsx | 64 ++ .../stores/useProcessBuilderStore.ts | 26 +- .../components/decisions/rubricTemplate.ts | 587 ++++++++++++++++++ .../ui/src/components/FieldConfigCard.tsx | 284 +++++++-- 4 files changed, 892 insertions(+), 69 deletions(-) create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx create mode 100644 apps/app/src/components/decisions/rubricTemplate.ts diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx new file mode 100644 index 000000000..ebcd4ce7e --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx @@ -0,0 +1,64 @@ +import type { IconType } from 'react-icons'; +import { + LuChevronDown, + LuHash, + LuLetterText, + LuToggleRight, +} from 'react-icons/lu'; + +import type { RubricCriterionType } from '../../../../decisions/rubricTemplate'; + +/** + * Display metadata for each rubric criterion type. + */ +interface CriterionTypeRegistryEntry { + icon: IconType; + /** Translation key for the type label */ + labelKey: string; + /** Translation key for a short description shown in the radio selector */ + descriptionKey: string; +} + +export const CRITERION_TYPE_REGISTRY: Record< + RubricCriterionType, + CriterionTypeRegistryEntry +> = { + scored: { + icon: LuHash, + labelKey: 'Scored', + descriptionKey: 'Rate on a numeric scale with points', + }, + yes_no: { + icon: LuToggleRight, + labelKey: 'Yes / No', + descriptionKey: 'Simple yes or no answer', + }, + dropdown: { + icon: LuChevronDown, + labelKey: 'Dropdown', + descriptionKey: 'Select from custom options', + }, + long_text: { + icon: LuLetterText, + labelKey: 'Long text', + descriptionKey: 'Open-ended written feedback', + }, +}; + +/** + * Ordered list of criterion types for the radio selector. + */ +export const CRITERION_TYPES: RubricCriterionType[] = [ + 'scored', + 'yes_no', + 'dropdown', + 'long_text', +]; + +export function getCriterionIcon(type: RubricCriterionType): IconType { + return CRITERION_TYPE_REGISTRY[type].icon; +} + +export function getCriterionLabelKey(type: RubricCriterionType): string { + return CRITERION_TYPE_REGISTRY[type].labelKey; +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts b/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts index 616f7fc1f..f6edba442 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts @@ -31,7 +31,7 @@ * - `saveStates[decisionId]` - UI save indicator state */ import type { InstanceData, InstancePhaseData } from '@op/api/encoders'; -import type { ProposalTemplateSchema } from '@op/common'; +import type { ProposalTemplateSchema, RubricTemplateSchema } from '@op/common'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; @@ -101,6 +101,15 @@ interface ProcessBuilderState { decisionId: string, ) => ProposalTemplateSchema | undefined; + // Actions for rubric template + setRubricTemplateSchema: ( + decisionId: string, + template: RubricTemplateSchema, + ) => void; + getRubricTemplateSchema: ( + decisionId: string, + ) => RubricTemplateSchema | undefined; + // Actions for save state setSaveStatus: (decisionId: string, status: SaveStatus) => void; markSaved: (decisionId: string) => void; @@ -190,6 +199,21 @@ export const useProcessBuilderStore = create()( getProposalTemplateSchema: (decisionId) => get().instances[decisionId]?.proposalTemplate, + // Rubric template actions + setRubricTemplateSchema: (decisionId, template) => + set((state) => ({ + instances: { + ...state.instances, + [decisionId]: { + ...state.instances[decisionId], + rubricTemplate: template, + }, + }, + })), + + getRubricTemplateSchema: (decisionId) => + get().instances[decisionId]?.rubricTemplate, + // Save state actions setSaveStatus: (decisionId, status) => set((state) => ({ diff --git a/apps/app/src/components/decisions/rubricTemplate.ts b/apps/app/src/components/decisions/rubricTemplate.ts new file mode 100644 index 000000000..696f1b679 --- /dev/null +++ b/apps/app/src/components/decisions/rubricTemplate.ts @@ -0,0 +1,587 @@ +/** + * Rubric Template — JSON Schema utilities. + * + * Uses `RubricTemplateSchema` from `@op/common`. Field ordering is stored as a + * top-level `x-field-order` array. Per-criterion widget selection is driven by + * `x-format` on each property (consumed by the renderer's rubric field logic). + * + * Mirrors the architecture of `proposalTemplate.ts` but tailored to rubric + * criteria: scored dropdowns, yes/no, custom dropdowns, and long text. + */ +import type { + RubricTemplateSchema, + XFormatPropertySchema, +} from '@op/common/client'; + +export type { RubricTemplateSchema }; + +// --------------------------------------------------------------------------- +// Criterion types +// --------------------------------------------------------------------------- + +export type RubricCriterionType = + | 'scored' + | 'yes_no' + | 'dropdown' + | 'long_text'; + +/** + * Flat read-only view of a single rubric criterion, derived from the template. + * Gives builder/renderer code a friendly object instead of requiring + * multiple reader calls per criterion. + */ +export interface CriterionView { + id: string; + criterionType: RubricCriterionType; + label: string; + description?: string; + required: boolean; + /** Maximum points for scored criteria. */ + maxPoints?: number; + /** Labels for each score level (index 0 = score 1). Scored criteria only. */ + scoreLabels: string[]; + /** Options for dropdown criteria. */ + options: { id: string; value: string }[]; +} + +// --------------------------------------------------------------------------- +// Criterion type ↔ JSON Schema mapping +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_POINTS = 5; + +const DEFAULT_SCORE_LABELS: Record = { + 1: 'Poor', + 2: 'Below Average', + 3: 'Average', + 4: 'Good', + 5: 'Excellent', +}; + +/** + * Create the JSON Schema for a given criterion type. + */ +export function createCriterionJsonSchema( + type: RubricCriterionType, +): XFormatPropertySchema { + switch (type) { + case 'scored': { + const max = DEFAULT_MAX_POINTS; + const oneOf = Array.from({ length: max }, (_, i) => ({ + const: i + 1, + title: DEFAULT_SCORE_LABELS[i + 1] ?? `${i + 1}`, + })); + return { + type: 'integer', + 'x-format': 'dropdown', + minimum: 1, + maximum: max, + oneOf, + }; + } + case 'yes_no': + return { + type: 'string', + 'x-format': 'dropdown', + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + }; + case 'dropdown': + return { + type: 'string', + 'x-format': 'dropdown', + oneOf: [ + { const: 'Option 1', title: 'Option 1' }, + { const: 'Option 2', title: 'Option 2' }, + ], + }; + case 'long_text': + return { + type: 'string', + 'x-format': 'long-text', + }; + } +} + +// --------------------------------------------------------------------------- +// Type inference from schema shape +// --------------------------------------------------------------------------- + +/** + * Infer the `RubricCriterionType` from a raw JSON Schema property. + */ +export function inferCriterionType( + schema: XFormatPropertySchema, +): RubricCriterionType | undefined { + const xFormat = schema['x-format']; + + if (xFormat === 'long-text') { + return 'long_text'; + } + + if (xFormat === 'dropdown') { + // Scored: integer type with maximum + if (schema.type === 'integer' && typeof schema.maximum === 'number') { + return 'scored'; + } + + // Yes/No: string with exactly two oneOf entries (yes, no) + if (schema.type === 'string' && Array.isArray(schema.oneOf)) { + const values = schema.oneOf + .filter( + (e): e is { const: string } => + typeof e === 'object' && e !== null && 'const' in e, + ) + .map((e) => e.const); + if ( + values.length === 2 && + values.includes('yes') && + values.includes('no') + ) { + return 'yes_no'; + } + } + + // Generic dropdown + if (schema.type === 'string') { + return 'dropdown'; + } + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// Readers +// --------------------------------------------------------------------------- + +function asSchema(def: unknown): XFormatPropertySchema | undefined { + if (typeof def === 'object' && def !== null) { + return def as XFormatPropertySchema; + } + return undefined; +} + +export function getCriterionOrder(template: RubricTemplateSchema): string[] { + return (template['x-field-order'] as string[] | undefined) ?? []; +} + +export function getCriterionSchema( + template: RubricTemplateSchema, + criterionId: string, +): XFormatPropertySchema | undefined { + const props = template.properties; + if (!props) { + return undefined; + } + return asSchema(props[criterionId]); +} + +export function getCriterionType( + template: RubricTemplateSchema, + criterionId: string, +): RubricCriterionType | undefined { + const schema = getCriterionSchema(template, criterionId); + if (!schema) { + return undefined; + } + return inferCriterionType(schema); +} + +export function getCriterionLabel( + template: RubricTemplateSchema, + criterionId: string, +): string { + const schema = getCriterionSchema(template, criterionId); + return (schema?.title as string | undefined) ?? ''; +} + +export function getCriterionDescription( + template: RubricTemplateSchema, + criterionId: string, +): string | undefined { + const schema = getCriterionSchema(template, criterionId); + return schema?.description; +} + +export function isCriterionRequired( + template: RubricTemplateSchema, + criterionId: string, +): boolean { + const required = template.required; + if (!Array.isArray(required)) { + return false; + } + return required.includes(criterionId); +} + +export function getCriterionMaxPoints( + template: RubricTemplateSchema, + criterionId: string, +): number | undefined { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'integer') { + return undefined; + } + return schema.maximum; +} + +export function getCriterionScoreLabels( + template: RubricTemplateSchema, + criterionId: string, +): string[] { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'integer' || !Array.isArray(schema.oneOf)) { + return []; + } + return schema.oneOf + .filter( + (e): e is { const: number; title: string } => + typeof e === 'object' && + e !== null && + 'const' in e && + 'title' in e && + typeof (e as { title: unknown }).title === 'string', + ) + .sort((a, b) => a.const - b.const) + .map((e) => e.title); +} + +export function getCriterionOptions( + template: RubricTemplateSchema, + criterionId: string, +): { id: string; value: string }[] { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'string' || !Array.isArray(schema.oneOf)) { + return []; + } + return schema.oneOf + .filter( + (e): e is { const: string; title: string } => + typeof e === 'object' && + e !== null && + 'const' in e && + 'title' in e && + typeof (e as { const: unknown }).const === 'string', + ) + .map((e, i) => ({ + id: `${criterionId}-opt-${i}`, + value: e.title, + })); +} + +// --------------------------------------------------------------------------- +// Composite readers +// --------------------------------------------------------------------------- + +export function getCriterion( + template: RubricTemplateSchema, + criterionId: string, +): CriterionView | undefined { + const criterionType = getCriterionType(template, criterionId); + if (!criterionType) { + return undefined; + } + + return { + id: criterionId, + criterionType, + label: getCriterionLabel(template, criterionId), + description: getCriterionDescription(template, criterionId), + required: isCriterionRequired(template, criterionId), + maxPoints: getCriterionMaxPoints(template, criterionId), + scoreLabels: getCriterionScoreLabels(template, criterionId), + options: getCriterionOptions(template, criterionId), + }; +} + +export function getCriteria(template: RubricTemplateSchema): CriterionView[] { + const order = getCriterionOrder(template); + const criteria: CriterionView[] = []; + for (const id of order) { + const criterion = getCriterion(template, id); + if (criterion) { + criteria.push(criterion); + } + } + return criteria; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Returns translation keys for validation errors on a criterion. + * Pass each key through `t()` in the UI layer. + */ +export function getCriterionErrors(criterion: CriterionView): string[] { + const errors: string[] = []; + + if (!criterion.label.trim()) { + errors.push('Criterion label is required'); + } + + if (criterion.criterionType === 'dropdown') { + if (criterion.options.length < 2) { + errors.push('At least two options are required'); + } + if (criterion.options.some((o) => !o.value.trim())) { + errors.push('Options cannot be empty'); + } + } + + if (criterion.criterionType === 'scored') { + if (criterion.scoreLabels.some((l) => !l.trim())) { + errors.push('Score labels cannot be empty'); + } + } + + return errors; +} + +// --------------------------------------------------------------------------- +// Immutable mutators — each returns a new template +// --------------------------------------------------------------------------- + +export function addCriterion( + template: RubricTemplateSchema, + criterionId: string, + type: RubricCriterionType, + label: string, +): RubricTemplateSchema { + const jsonSchema = { ...createCriterionJsonSchema(type), title: label }; + const order = getCriterionOrder(template); + + return { + ...template, + properties: { + ...template.properties, + [criterionId]: jsonSchema, + }, + 'x-field-order': [...order, criterionId], + }; +} + +export function removeCriterion( + template: RubricTemplateSchema, + criterionId: string, +): RubricTemplateSchema { + const { [criterionId]: _removed, ...restProps } = template.properties ?? {}; + const order = getCriterionOrder(template).filter((id) => id !== criterionId); + const required = (template.required ?? []).filter((id) => id !== criterionId); + + return { + ...template, + properties: restProps, + required: required.length > 0 ? required : undefined, + 'x-field-order': order, + }; +} + +export function reorderCriteria( + template: RubricTemplateSchema, + newOrder: string[], +): RubricTemplateSchema { + return { + ...template, + 'x-field-order': newOrder, + }; +} + +export function updateCriterionLabel( + template: RubricTemplateSchema, + criterionId: string, + label: string, +): RubricTemplateSchema { + const schema = getCriterionSchema(template, criterionId); + if (!schema) { + return template; + } + + return { + ...template, + properties: { + ...template.properties, + [criterionId]: { ...schema, title: label }, + }, + }; +} + +export function updateCriterionDescription( + template: RubricTemplateSchema, + criterionId: string, + description: string | undefined, +): RubricTemplateSchema { + const schema = getCriterionSchema(template, criterionId); + if (!schema) { + return template; + } + + const updated = { ...schema }; + if (description) { + updated.description = description; + } else { + delete updated.description; + } + + return { + ...template, + properties: { + ...template.properties, + [criterionId]: updated, + }, + }; +} + +export function setCriterionRequired( + template: RubricTemplateSchema, + criterionId: string, + required: boolean, +): RubricTemplateSchema { + const current = template.required ?? []; + const filtered = current.filter((id) => id !== criterionId); + const next = required ? [...filtered, criterionId] : filtered; + + return { + ...template, + required: next.length > 0 ? next : undefined, + }; +} + +/** + * Change a criterion's type while preserving its label, description, and + * required status. The schema is rebuilt from scratch for the new type. + */ +export function changeCriterionType( + template: RubricTemplateSchema, + criterionId: string, + newType: RubricCriterionType, +): RubricTemplateSchema { + const existing = getCriterionSchema(template, criterionId); + if (!existing) { + return template; + } + + const newSchema: XFormatPropertySchema = { + ...createCriterionJsonSchema(newType), + title: existing.title, + }; + if (existing.description) { + newSchema.description = existing.description; + } + + return { + ...template, + properties: { + ...template.properties, + [criterionId]: newSchema, + }, + }; +} + +/** + * Low-level updater for the raw JSON Schema of a criterion. + * Used for updating score labels, max points, dropdown options, etc. + */ +export function updateCriterionJsonSchema( + template: RubricTemplateSchema, + criterionId: string, + updates: Partial, +): RubricTemplateSchema { + const existing = getCriterionSchema(template, criterionId); + if (!existing) { + return template; + } + + return { + ...template, + properties: { + ...template.properties, + [criterionId]: { ...existing, ...updates }, + }, + }; +} + +/** + * Update the maximum points for a scored criterion. + * Rebuilds the `oneOf` array to match the new max, preserving existing + * labels where possible and generating defaults for new levels. + */ +export function updateScoredMaxPoints( + template: RubricTemplateSchema, + criterionId: string, + newMax: number, +): RubricTemplateSchema { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'integer') { + return template; + } + + const clampedMax = Math.max(2, Math.min(newMax, 10)); + const existingLabels = getCriterionScoreLabels(template, criterionId); + + const oneOf = Array.from({ length: clampedMax }, (_, i) => ({ + const: i + 1, + title: existingLabels[i] ?? DEFAULT_SCORE_LABELS[i + 1] ?? `${i + 1}`, + })); + + return { + ...template, + properties: { + ...template.properties, + [criterionId]: { + ...schema, + maximum: clampedMax, + oneOf, + }, + }, + }; +} + +/** + * Update a single score label for a scored criterion. + */ +export function updateScoreLabel( + template: RubricTemplateSchema, + criterionId: string, + scoreIndex: number, + label: string, +): RubricTemplateSchema { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'integer' || !Array.isArray(schema.oneOf)) { + return template; + } + + const oneOf = schema.oneOf.map((entry, i) => { + if (i === scoreIndex && typeof entry === 'object' && entry !== null) { + return { ...entry, title: label }; + } + return entry; + }); + + return { + ...template, + properties: { + ...template.properties, + [criterionId]: { ...schema, oneOf }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Creates an empty rubric template with no criteria. + */ +export function createEmptyRubricTemplate(): RubricTemplateSchema { + return { + type: 'object', + properties: {}, + 'x-field-order': [], + }; +} diff --git a/packages/ui/src/components/FieldConfigCard.tsx b/packages/ui/src/components/FieldConfigCard.tsx index 2c2572327..e29790470 100644 --- a/packages/ui/src/components/FieldConfigCard.tsx +++ b/packages/ui/src/components/FieldConfigCard.tsx @@ -1,9 +1,16 @@ 'use client'; import type { ReactNode } from 'react'; -import { useRef } from 'react'; -import { Button as AriaButton } from 'react-aria-components'; -import { LuGripVertical, LuLock, LuX } from 'react-icons/lu'; +import { use, useLayoutEffect, useRef } from 'react'; +import type { Key } from 'react-aria-components'; +import { + Button as AriaButton, + DisclosurePanel as DisclosurePanelPrimitive, + Disclosure as DisclosurePrimitive, + DisclosureStateContext, + Button as TriggerButton, +} from 'react-aria-components'; +import { LuChevronRight, LuGripVertical, LuLock, LuX } from 'react-icons/lu'; import { cn } from '../lib/utils'; import { AutoSizeInput } from './AutoSizeInput'; @@ -46,12 +53,37 @@ export interface FieldConfigCardProps { className?: string; /** Whether the card is locked (non-editable, no drag handle or remove button) */ locked?: boolean; + /** Extra content rendered in the header row after the label (e.g. points badge) */ + headerExtra?: ReactNode; + + // --- Collapsible (accordion) props --- + + /** + * Enable accordion/collapsible behavior. When true, the body (description + + * children) is hidden behind a disclosure toggle and animates open/closed. + * The card must be placed inside an `` (DisclosureGroup) for + * group-level expand control, or used standalone with controlled/uncontrolled + * expansion via `isExpanded` / `defaultExpanded`. + */ + collapsible?: boolean; + /** Unique id for this disclosure (required when inside an Accordion group) */ + disclosureId?: Key; + /** Controlled expansion state */ + isExpanded?: boolean; + /** Default expansion state (uncontrolled) */ + defaultExpanded?: boolean; + /** Callback when expansion changes */ + onExpandedChange?: (isExpanded: boolean) => void; } /** * A configurable card component for form builders. * Features a header with drag handle, icon, editable label, and remove button, * plus an optional description field and slot for additional configuration. + * + * When `collapsible` is true the body content is wrapped in a React Aria + * Disclosure panel with smooth height animation, and a chevron toggle is + * added to the header. */ export function FieldConfigCard({ icon: Icon, @@ -70,6 +102,12 @@ export function FieldConfigCard({ children, className, locked = false, + headerExtra, + collapsible = false, + disclosureId, + isExpanded, + defaultExpanded, + onExpandedChange, }: FieldConfigCardProps) { const isDragging = controls?.isDragging ?? false; const labelInputRef = useRef(null!); @@ -94,88 +132,198 @@ export function FieldConfigCard({ {iconTooltip && {iconTooltip}} {label} + {headerExtra} {children} ); } + const header = ( +
+
+ {controls && ( + + )} + {collapsible && } +
+ + labelInputRef.current?.focus()} + > + + + {iconTooltip && {iconTooltip}} + +
+ onLabelChange?.(value)} + className="text-neutral-charcoal" + aria-label={labelInputAriaLabel} + /> +
+
+ {headerExtra} +
+ {onRemove && ( + + )} +
+ ); + + const body = ( +
+ {onDescriptionChange && ( +
+ +
+ )} + {children} +
+ ); + + // Non-collapsible variant: render header + body directly + if (!collapsible) { + return ( +
+ {header} + {body} +
+ ); + } + + // Collapsible variant: wrap in Disclosure primitive return ( -
- {/* Header: drag handle, icon, label input, remove button */} -
-
- {controls && ( - - )} -
- - labelInputRef.current?.focus()} - > - - - {iconTooltip && {iconTooltip}} - -
- onLabelChange?.(value)} - className="text-neutral-charcoal" - aria-label={labelInputAriaLabel} - /> -
-
-
- {onRemove && ( - - )} -
+ {header} + {body} + + ); +} - {/* Body: description and custom config */} -
- {/* Description field */} - {onDescriptionChange && ( -
- -
- )} +// --------------------------------------------------------------------------- +// Collapsible sub-components +// --------------------------------------------------------------------------- - {/* Additional config slot */} - {children} -
-
+/** + * Chevron indicator that toggles the disclosure. Rotates 90° when expanded. + * Rendered as a React Aria `Button` with `slot="trigger"` so it integrates + * with the parent `Disclosure` primitive. + */ +function CollapsibleIndicator() { + return ( + + + ); } +/** + * Animated disclosure panel that mirrors the height-transition logic from + * the Accordion component. Uses the `--disclosure-panel-height` CSS custom + * property for smooth expand/collapse animations. + */ +function AnimatedDisclosurePanel({ children }: { children: ReactNode }) { + const state = use(DisclosureStateContext); + const panelRef = useRef(null); + const isFirstRender = useRef(true); + + useLayoutEffect(() => { + const panel = panelRef.current; + if (!panel) { + return; + } + + if (isFirstRender.current) { + isFirstRender.current = false; + if (state?.isExpanded) { + panel.style.setProperty('--disclosure-panel-height', 'auto'); + } else { + panel.style.setProperty('--disclosure-panel-height', '0px'); + } + return; + } + + if (state?.isExpanded) { + if ( + panel.style.getPropertyValue('--disclosure-panel-height') === 'auto' + ) { + return; + } + + panel.removeAttribute('hidden'); + const height = panel.scrollHeight; + panel.style.setProperty('--disclosure-panel-height', `${height}px`); + + const onTransitionEnd = () => { + panel.style.setProperty('--disclosure-panel-height', 'auto'); + panel.removeEventListener('transitionend', onTransitionEnd); + }; + panel.addEventListener('transitionend', onTransitionEnd); + } else { + const height = panel.scrollHeight; + panel.style.setProperty('--disclosure-panel-height', `${height}px`); + void panel.offsetHeight; + panel.style.setProperty('--disclosure-panel-height', '0px'); + } + }, [state?.isExpanded]); + + return ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Drag preview +// --------------------------------------------------------------------------- + export interface FieldConfigCardDragPreviewProps { /** Icon component to display next to the label */ icon: React.ComponentType<{ className?: string }>; From 2e930c8be4e968e84c9d7bed926ad2c393abb1fc Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 12:59:43 -0500 Subject: [PATCH 02/38] Add rubric editor UI with collapsible criterion cards and auto-save - Create RubricCriterionCard with collapsible FieldConfigCard, inline criterion type radio selector, scored/dropdown/yes-no/long-text configs - Create RubricEditorContent with Accordion+Sortable for drag-to-reorder, debounced auto-save to API, live RubricParticipantPreview, and empty state - Wire RubricEditorContent into CriteriaSection behind rubric_builder flag --- .../stepContent/rubric/CriteriaSection.tsx | 14 +- .../rubric/RubricCriterionCard.tsx | 476 ++++++++++++++++++ .../rubric/RubricEditorContent.tsx | 318 ++++++++++++ 3 files changed, 797 insertions(+), 11 deletions(-) create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx index 509c08ac0..bcd6ba78e 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx @@ -7,8 +7,7 @@ import { useTranslations } from '@/lib/i18n'; import type { SectionProps } from '../../contentRegistry'; import { CodeAnimation } from './RubricComingSoonAnimation'; -import { RubricParticipantPreview } from './RubricParticipantPreview'; -import { DUMMY_RUBRIC_TEMPLATE } from './dummyRubricTemplate'; +import { RubricEditorContent } from './RubricEditorContent'; export default function CriteriaSection(props: SectionProps) { return ( @@ -18,19 +17,12 @@ export default function CriteriaSection(props: SectionProps) { ); } -function CriteriaSectionContent(_props: SectionProps) { +function CriteriaSectionContent(props: SectionProps) { const t = useTranslations(); const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); if (rubricBuilderEnabled) { - return ( -
- {/* Left panel — placeholder for the future rubric builder */} -
- - -
- ); + return ; } return ( diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx new file mode 100644 index 000000000..4c48d9609 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx @@ -0,0 +1,476 @@ +'use client'; + +import type { XFormatPropertySchema } from '@op/common/client'; +import { Button } from '@op/ui/Button'; +import { + FieldConfigCard, + FieldConfigCardDragPreview, +} from '@op/ui/FieldConfigCard'; +import { NumberField } from '@op/ui/NumberField'; +import { Radio, RadioGroup } from '@op/ui/RadioGroup'; +import { DragHandle, Sortable } from '@op/ui/Sortable'; +import type { SortableItemControls } from '@op/ui/Sortable'; +import { TextField } from '@op/ui/TextField'; +import { ToggleButton } from '@op/ui/ToggleButton'; +import { Tooltip, TooltipTrigger } from '@op/ui/Tooltip'; +import { useEffect, useRef, useState } from 'react'; +import { LuGripVertical, LuPlus, LuX } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +import type { + CriterionView, + RubricCriterionType, +} from '../../../../decisions/rubricTemplate'; +import { + CRITERION_TYPES, + CRITERION_TYPE_REGISTRY, + getCriterionIcon, +} from './rubricCriterionRegistry'; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface RubricCriterionCardProps { + criterion: CriterionView; + errors?: string[]; + controls?: SortableItemControls; + onRemove?: (criterionId: string) => void; + onBlur?: (criterionId: string) => void; + onUpdateLabel?: (criterionId: string, label: string) => void; + onUpdateDescription?: (criterionId: string, description: string) => void; + onUpdateRequired?: (criterionId: string, isRequired: boolean) => void; + onChangeType?: (criterionId: string, newType: RubricCriterionType) => void; + onUpdateJsonSchema?: ( + criterionId: string, + updates: Partial, + ) => void; + onUpdateMaxPoints?: (criterionId: string, maxPoints: number) => void; + onUpdateScoreLabel?: ( + criterionId: string, + scoreIndex: number, + label: string, + ) => void; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +/** + * A collapsible card representing a single rubric criterion in the builder. + * Uses FieldConfigCard with `collapsible` prop for accordion behaviour. + * + * Collapsed: drag handle + chevron + icon + label + badge + remove + * Expanded: description, criterion type radio, type-specific config, required toggle + */ +export function RubricCriterionCard({ + criterion, + errors = [], + controls, + onRemove, + onBlur, + onUpdateLabel, + onUpdateDescription, + onUpdateRequired, + onChangeType, + onUpdateJsonSchema, + onUpdateMaxPoints, + onUpdateScoreLabel, +}: RubricCriterionCardProps) { + const t = useTranslations(); + const cardRef = useRef(null); + + const Icon = getCriterionIcon(criterion.criterionType); + + const handleBlur = (e: React.FocusEvent) => { + if (cardRef.current && !cardRef.current.contains(e.relatedTarget as Node)) { + onBlur?.(criterion.id); + } + }; + + // Badge for the header + const headerBadge = (() => { + switch (criterion.criterionType) { + case 'scored': + return ( + + {criterion.maxPoints} {t('pts')} + + ); + case 'yes_no': + return ( + + {t('Yes / No')} + + ); + default: + return null; + } + })(); + + return ( +
+ onUpdateLabel?.(criterion.id, newLabel)} + labelInputAriaLabel={t('Criterion label')} + description={criterion.description} + onDescriptionChange={(desc) => + onUpdateDescription?.(criterion.id, desc) + } + descriptionLabel={t('Description')} + descriptionPlaceholder={t( + 'Provide guidance for reviewers on how to evaluate this criterion...', + )} + onRemove={onRemove ? () => onRemove(criterion.id) : undefined} + removeAriaLabel={t('Remove criterion')} + dragHandleAriaLabel={t('Drag to reorder {field}', { + field: criterion.label, + })} + controls={controls} + headerExtra={headerBadge} + className={errors.length > 0 ? 'border-functional-red' : undefined} + > + {/* Criterion type selector */} +
+ onChangeType?.(criterion.id, newType)} + /> +
+ + {/* Type-specific configuration */} + {criterion.criterionType === 'scored' && ( +
+ + onUpdateMaxPoints?.(criterion.id, max) + } + onUpdateScoreLabel={(index, label) => + onUpdateScoreLabel?.(criterion.id, index, label) + } + /> +
+ )} + + {criterion.criterionType === 'dropdown' && ( +
+ + onUpdateJsonSchema?.(criterion.id, updates) + } + /> +
+ )} + + {/* Validation errors */} + {errors.length > 0 && ( +
+ {errors.map((error) => ( +

+ {t(error)} +

+ ))} +
+ )} + + {/* Required toggle */} +
+ {t('Required?')} + + onUpdateRequired?.(criterion.id, isSelected) + } + aria-label={t('Required')} + /> +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Criterion type radio selector +// --------------------------------------------------------------------------- + +function CriterionTypeSelector({ + value, + onChange, +}: { + value: RubricCriterionType; + onChange: (type: RubricCriterionType) => void; +}) { + const t = useTranslations(); + + return ( + onChange(newValue as RubricCriterionType)} + orientation="vertical" + > + {CRITERION_TYPES.map((type) => { + const entry = CRITERION_TYPE_REGISTRY[type]; + const TypeIcon = entry.icon; + return ( + +
+ + {t(entry.labelKey)} + + {t(entry.descriptionKey)} + +
+
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Scored criterion config (max points + score labels) +// --------------------------------------------------------------------------- + +function ScoredCriterionConfig({ + criterion, + onUpdateMaxPoints, + onUpdateScoreLabel, +}: { + criterion: CriterionView; + onUpdateMaxPoints: (max: number) => void; + onUpdateScoreLabel: (index: number, label: string) => void; +}) { + const t = useTranslations(); + const max = criterion.maxPoints ?? 5; + + return ( +
+ { + if (value !== null && value >= 2 && value <= 10) { + onUpdateMaxPoints(value); + } + }} + inputProps={{ className: 'w-20' }} + /> + +
+

{t('Score labels')}

+ {criterion.scoreLabels.map((label, index) => ( +
+ + {index + 1} + + onUpdateScoreLabel(index, value)} + inputProps={{ + placeholder: t('Label for score {number}', { + number: index + 1, + }), + }} + className="w-full" + /> +
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Dropdown criterion config (custom options list) +// Reuses the same sortable options pattern as FieldConfigDropdown +// --------------------------------------------------------------------------- + +interface DropdownOption { + id: string; + value: string; +} + +function DropdownCriterionConfig({ + criterion, + onUpdateJsonSchema, +}: { + criterion: CriterionView; + onUpdateJsonSchema: (updates: Partial) => void; +}) { + const t = useTranslations(); + const containerRef = useRef(null); + const shouldFocusNewRef = useRef(false); + + const [options, setOptions] = useState(() => + criterion.options.map((o) => ({ ...o, id: crypto.randomUUID() })), + ); + + const updateOptions = (next: DropdownOption[]) => { + setOptions(next); + const oneOfValues = next.map((o) => ({ + const: o.value, + title: o.value, + })); + onUpdateJsonSchema({ oneOf: oneOfValues }); + }; + + // Focus the last input when a new option is added + useEffect(() => { + if (shouldFocusNewRef.current && containerRef.current) { + const inputs = containerRef.current.querySelectorAll( + 'input[type="text"]', + ) as NodeListOf; + const lastInput = inputs[inputs.length - 1]; + lastInput?.focus(); + shouldFocusNewRef.current = false; + } + }, [options.length]); + + const handleAddOption = () => { + shouldFocusNewRef.current = true; + updateOptions([...options, { id: crypto.randomUUID(), value: '' }]); + }; + + const handleUpdateOption = (id: string, value: string) => { + updateOptions( + options.map((opt) => (opt.id === id ? { ...opt, value } : opt)), + ); + }; + + const handleRemoveOption = (id: string) => { + updateOptions(options.filter((opt) => opt.id !== id)); + }; + + const handleKeyDown = (e: React.KeyboardEvent, option: DropdownOption) => { + if (e.key === 'Enter') { + e.preventDefault(); + const isLastOption = options[options.length - 1]?.id === option.id; + if (isLastOption && option.value.trim()) { + handleAddOption(); + } + } + }; + + return ( +
+

{t('Options')}

+ + item.value || t('Option')} + renderDragPreview={(items) => { + const item = items[0]; + if (!item) { + return null; + } + return ( +
+ + + {item.value || t('Option')} + +
+ ); + }} + className="gap-2" + aria-label={t('Dropdown options')} + > + {(option, controls) => { + const index = options.findIndex((o) => o.id === option.id); + return ( +
+ + handleUpdateOption(option.id, value)} + onKeyDown={(e) => handleKeyDown(e, option)} + inputProps={{ + placeholder: t('Option {number}', { number: index + 1 }), + }} + className="w-full" + /> + 2}> + + {t('At least two options are required')} + +
+ ); + }} +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Drag preview +// --------------------------------------------------------------------------- + +export function RubricCriterionDragPreview({ + criterion, +}: { + criterion: CriterionView; +}) { + const Icon = getCriterionIcon(criterion.criterionType); + return ; +} + +export function RubricCriterionDropIndicator() { + return ( +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx new file mode 100644 index 000000000..59d0357af --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import type { RubricTemplateSchema } from '@op/common/client'; +import { useDebouncedCallback } from '@op/hooks'; +import { Accordion } from '@op/ui/Accordion'; +import { Button } from '@op/ui/Button'; +import { Header2 } from '@op/ui/Header'; +import type { Key } from '@op/ui/RAC'; +import { Sortable } from '@op/ui/Sortable'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { LuPlus } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +import type { + CriterionView, + RubricCriterionType, +} from '../../../../decisions/rubricTemplate'; +import { + addCriterion, + changeCriterionType, + createEmptyRubricTemplate, + getCriteria, + getCriterion, + getCriterionErrors, + removeCriterion, + reorderCriteria, + setCriterionRequired, + updateCriterionDescription, + updateCriterionJsonSchema, + updateCriterionLabel, + updateScoreLabel, + updateScoredMaxPoints, +} from '../../../../decisions/rubricTemplate'; +import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; +import type { SectionProps } from '../../contentRegistry'; +import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +import { + RubricCriterionCard, + RubricCriterionDragPreview, + RubricCriterionDropIndicator, +} from './RubricCriterionCard'; +import { RubricParticipantPreview } from './RubricParticipantPreview'; + +const AUTOSAVE_DEBOUNCE_MS = 1000; + +export function RubricEditorContent({ + decisionProfileId, + instanceId, +}: SectionProps) { + const t = useTranslations(); + + // Load instance data from the backend + const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); + const isDraft = instance.status === 'draft'; + const utils = trpc.useUtils(); + const instanceData = instance.instanceData; + + const initialTemplate = useMemo(() => { + const saved = instanceData?.rubricTemplate; + if (saved && Object.keys(saved.properties ?? {}).length > 0) { + return saved as RubricTemplateSchema; + } + return createEmptyRubricTemplate(); + }, [instanceData?.rubricTemplate]); + + const [template, setTemplate] = + useState(initialTemplate); + const isInitialLoadRef = useRef(true); + + // Validation: "show on blur, clear on change" + const [criterionErrors, setCriterionErrors] = useState>( + new Map(), + ); + + // Accordion expansion state + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + + const setRubricTemplateSchema = useProcessBuilderStore( + (s) => s.setRubricTemplateSchema, + ); + const setSaveStatus = useProcessBuilderStore((s) => s.setSaveStatus); + const markSaved = useProcessBuilderStore((s) => s.markSaved); + const saveState = useProcessBuilderStore((s) => + s.getSaveState(decisionProfileId), + ); + + const debouncedSaveRef = useRef<() => boolean>(null); + const updateInstance = trpc.decision.updateDecisionInstance.useMutation({ + onSuccess: () => markSaved(decisionProfileId), + onError: () => setSaveStatus(decisionProfileId, 'error'), + onSettled: () => { + if (debouncedSaveRef.current?.()) { + return; + } + void utils.decision.getInstance.invalidate({ instanceId }); + }, + }); + + // Derive criterion views from the template + const criteria = useMemo(() => getCriteria(template), [template]); + + // Debounced auto-save + const debouncedSave = useDebouncedCallback( + (updatedTemplate: RubricTemplateSchema) => { + setRubricTemplateSchema(decisionProfileId, updatedTemplate); + + if (isDraft) { + updateInstance.mutate({ + instanceId, + rubricTemplate: updatedTemplate, + }); + } else { + markSaved(decisionProfileId); + } + }, + AUTOSAVE_DEBOUNCE_MS, + ); + debouncedSaveRef.current = () => debouncedSave.isPending(); + + // Trigger debounced save when template changes (skip initial load) + useEffect(() => { + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false; + return; + } + + setSaveStatus(decisionProfileId, 'saving'); + debouncedSave(template); + }, [template, decisionProfileId, setSaveStatus, debouncedSave]); + + // --- Handlers --- + + const handleAddCriterion = useCallback(() => { + const criterionId = crypto.randomUUID().slice(0, 8); + const label = t('New criterion'); + setTemplate((prev) => addCriterion(prev, criterionId, 'scored', label)); + setExpandedKeys((prev) => new Set([...prev, criterionId])); + }, [t]); + + const handleRemoveCriterion = useCallback((criterionId: string) => { + setTemplate((prev) => removeCriterion(prev, criterionId)); + setCriterionErrors((prev) => { + const next = new Map(prev); + next.delete(criterionId); + return next; + }); + }, []); + + const handleReorderCriteria = useCallback((newItems: CriterionView[]) => { + setTemplate((prev) => + reorderCriteria( + prev, + newItems.map((item) => item.id), + ), + ); + }, []); + + const handleUpdateLabel = useCallback( + (criterionId: string, label: string) => { + setTemplate((prev) => updateCriterionLabel(prev, criterionId, label)); + }, + [], + ); + + const handleUpdateDescription = useCallback( + (criterionId: string, description: string) => { + setTemplate((prev) => + updateCriterionDescription(prev, criterionId, description || undefined), + ); + }, + [], + ); + + const handleUpdateRequired = useCallback( + (criterionId: string, required: boolean) => { + setTemplate((prev) => setCriterionRequired(prev, criterionId, required)); + }, + [], + ); + + const handleChangeType = useCallback( + (criterionId: string, newType: RubricCriterionType) => { + setTemplate((prev) => changeCriterionType(prev, criterionId, newType)); + }, + [], + ); + + const handleUpdateJsonSchema = useCallback( + (criterionId: string, updates: Record) => { + setTemplate((prev) => + updateCriterionJsonSchema(prev, criterionId, updates), + ); + }, + [], + ); + + const handleUpdateMaxPoints = useCallback( + (criterionId: string, maxPoints: number) => { + setTemplate((prev) => + updateScoredMaxPoints(prev, criterionId, maxPoints), + ); + }, + [], + ); + + const handleUpdateScoreLabel = useCallback( + (criterionId: string, scoreIndex: number, label: string) => { + setTemplate((prev) => + updateScoreLabel(prev, criterionId, scoreIndex, label), + ); + }, + [], + ); + + const handleCriterionBlur = useCallback( + (criterionId: string) => { + const criterion = getCriterion(template, criterionId); + if (criterion) { + setCriterionErrors((prev) => + new Map(prev).set(criterionId, getCriterionErrors(criterion)), + ); + } + }, + [template], + ); + + return ( +
+
+
+
+ + {t('Rubric criteria')} + + +
+

+ {t('Define how reviewers will evaluate proposals')} +

+
+ + {criteria.length === 0 ? ( +
+

{t('No criteria defined')}

+
+ ) : ( + + criterion.label} + className="gap-3" + renderDragPreview={(items) => { + const criterion = items[0]; + if (!criterion) { + return null; + } + return ; + }} + renderDropIndicator={RubricCriterionDropIndicator} + aria-label={t('Rubric criteria')} + > + {(criterion, controls) => { + const snapshotErrors = + criterionErrors.get(criterion.id) ?? []; + const liveErrors = getCriterionErrors(criterion); + const displayedErrors = snapshotErrors.filter((e) => + liveErrors.includes(e), + ); + + return ( + + ); + }} + + + )} + + +
+
+ + +
+ ); +} From 7750e29f62926cf7711f75cf285582452fe480e8 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 13:02:46 -0500 Subject: [PATCH 03/38] Add translation keys for rubric editor UI to all language files --- apps/app/src/lib/i18n/dictionaries/bn.json | 20 +++++++++++++++++++- apps/app/src/lib/i18n/dictionaries/en.json | 20 +++++++++++++++++++- apps/app/src/lib/i18n/dictionaries/es.json | 20 +++++++++++++++++++- apps/app/src/lib/i18n/dictionaries/fr.json | 20 +++++++++++++++++++- apps/app/src/lib/i18n/dictionaries/pt.json | 20 +++++++++++++++++++- 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index aeaf1e2ea..7ccd4ce63 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -884,5 +884,23 @@ "Proposal Title": "প্রস্তাবের শিরোনাম", "Start typing...": "টাইপ করা শুরু করুন...", "Reason(s) and Insight(s)": "কারণ এবং অন্তর্দৃষ্টি", - "Placeholder": "প্লেসহোল্ডার" + "Placeholder": "প্লেসহোল্ডার", + "Add criterion": "মানদণ্ড যোগ করুন", + "Criterion label": "মানদণ্ডের লেবেল", + "Criterion type": "মানদণ্ডের ধরন", + "Define how reviewers will evaluate proposals": "পর্যালোচকরা কীভাবে প্রস্তাবগুলি মূল্যায়ন করবেন তা নির্ধারণ করুন", + "Label for score {number}": "স্কোর {number} এর লেবেল", + "Max points": "সর্বোচ্চ পয়েন্ট", + "New criterion": "নতুন মানদণ্ড", + "No criteria defined": "কোনো মানদণ্ড নির্ধারিত হয়নি", + "Open-ended written feedback": "উন্মুক্ত লিখিত প্রতিক্রিয়া", + "Provide guidance for reviewers on how to evaluate this criterion...": "পর্যালোচকদের জন্য এই মানদণ্ড মূল্যায়নের নির্দেশিকা প্রদান করুন...", + "Rate on a numeric scale with points": "পয়েন্ট সহ সংখ্যাসূচক স্কেলে রেটিং করুন", + "Remove criterion": "মানদণ্ড সরান", + "Rubric criteria": "রুব্রিক মানদণ্ড", + "Score labels": "স্কোর লেবেল", + "Scored": "স্কোরযুক্ত", + "Select from custom options": "কাস্টম বিকল্প থেকে নির্বাচন করুন", + "Simple yes or no answer": "সহজ হ্যাঁ বা না উত্তর", + "Yes / No": "হ্যাঁ / না" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index dfaadf48d..86abd4c8a 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -877,5 +877,23 @@ "Proposal Title": "Proposal Title", "Start typing...": "Start typing...", "Reason(s) and Insight(s)": "Reason(s) and Insight(s)", - "Placeholder": "Placeholder" + "Placeholder": "Placeholder", + "Add criterion": "Add criterion", + "Criterion label": "Criterion label", + "Criterion type": "Criterion type", + "Define how reviewers will evaluate proposals": "Define how reviewers will evaluate proposals", + "Label for score {number}": "Label for score {number}", + "Max points": "Max points", + "New criterion": "New criterion", + "No criteria defined": "No criteria defined", + "Open-ended written feedback": "Open-ended written feedback", + "Provide guidance for reviewers on how to evaluate this criterion...": "Provide guidance for reviewers on how to evaluate this criterion...", + "Rate on a numeric scale with points": "Rate on a numeric scale with points", + "Remove criterion": "Remove criterion", + "Rubric criteria": "Rubric criteria", + "Score labels": "Score labels", + "Scored": "Scored", + "Select from custom options": "Select from custom options", + "Simple yes or no answer": "Simple yes or no answer", + "Yes / No": "Yes / No" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index d87539f70..fb0a50c46 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -877,5 +877,23 @@ "Proposal Title": "Título de la Propuesta", "Start typing...": "Empieza a escribir...", "Reason(s) and Insight(s)": "Razón(es) y perspectiva(s)", - "Placeholder": "Marcador de posición" + "Placeholder": "Marcador de posición", + "Add criterion": "Agregar criterio", + "Criterion label": "Etiqueta del criterio", + "Criterion type": "Tipo de criterio", + "Define how reviewers will evaluate proposals": "Define cómo los revisores evaluarán las propuestas", + "Label for score {number}": "Etiqueta para puntuación {number}", + "Max points": "Puntos máximos", + "New criterion": "Nuevo criterio", + "No criteria defined": "No hay criterios definidos", + "Open-ended written feedback": "Retroalimentación escrita abierta", + "Provide guidance for reviewers on how to evaluate this criterion...": "Proporcione orientación a los revisores sobre cómo evaluar este criterio...", + "Rate on a numeric scale with points": "Calificar en una escala numérica con puntos", + "Remove criterion": "Eliminar criterio", + "Rubric criteria": "Criterios de rúbrica", + "Score labels": "Etiquetas de puntuación", + "Scored": "Puntuado", + "Select from custom options": "Seleccionar entre opciones personalizadas", + "Simple yes or no answer": "Respuesta simple de sí o no", + "Yes / No": "Sí / No" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 538bef132..fb310138d 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -877,5 +877,23 @@ "Proposal Title": "Titre de la Proposition", "Start typing...": "Commencez à taper...", "Reason(s) and Insight(s)": "Raison(s) et aperçu(s)", - "Placeholder": "Espace réservé" + "Placeholder": "Espace réservé", + "Add criterion": "Ajouter un critère", + "Criterion label": "Libellé du critère", + "Criterion type": "Type de critère", + "Define how reviewers will evaluate proposals": "Définissez comment les évaluateurs noteront les propositions", + "Label for score {number}": "Libellé pour la note {number}", + "Max points": "Points maximum", + "New criterion": "Nouveau critère", + "No criteria defined": "Aucun critère défini", + "Open-ended written feedback": "Commentaire écrit libre", + "Provide guidance for reviewers on how to evaluate this criterion...": "Fournissez des conseils aux évaluateurs sur la manière d'évaluer ce critère...", + "Rate on a numeric scale with points": "Noter sur une échelle numérique avec des points", + "Remove criterion": "Supprimer le critère", + "Rubric criteria": "Critères de la grille", + "Score labels": "Libellés des notes", + "Scored": "Noté", + "Select from custom options": "Sélectionner parmi des options personnalisées", + "Simple yes or no answer": "Réponse simple oui ou non", + "Yes / No": "Oui / Non" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 7fc1f8c44..9de09cd86 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -877,5 +877,23 @@ "Proposal Title": "Título da Proposta", "Start typing...": "Comece a digitar...", "Reason(s) and Insight(s)": "Razão(ões) e percepção(ões)", - "Placeholder": "Espaço reservado" + "Placeholder": "Espaço reservado", + "Add criterion": "Adicionar critério", + "Criterion label": "Rótulo do critério", + "Criterion type": "Tipo de critério", + "Define how reviewers will evaluate proposals": "Defina como os avaliadores irão avaliar as propostas", + "Label for score {number}": "Rótulo para a nota {number}", + "Max points": "Pontos máximos", + "New criterion": "Novo critério", + "No criteria defined": "Nenhum critério definido", + "Open-ended written feedback": "Feedback escrito aberto", + "Provide guidance for reviewers on how to evaluate this criterion...": "Forneça orientação aos avaliadores sobre como avaliar este critério...", + "Rate on a numeric scale with points": "Avaliar numa escala numérica com pontos", + "Remove criterion": "Remover critério", + "Rubric criteria": "Critérios da rubrica", + "Score labels": "Rótulos das notas", + "Scored": "Pontuado", + "Select from custom options": "Selecionar entre opções personalizadas", + "Simple yes or no answer": "Resposta simples de sim ou não", + "Yes / No": "Sim / Não" } From 8ba1a9c79b6107c779670317b8f449dce83bb641 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 14:38:09 -0500 Subject: [PATCH 04/38] Update rubric empty state to match ProposalCategories pattern Use EmptyState component with leaf icon, descriptive title/subtitle, and primary CTA button. Rename section title to 'Review Criteria'. --- .../rubric/RubricEditorContent.tsx | 150 ++++++++++-------- apps/app/src/lib/i18n/dictionaries/bn.json | 6 +- apps/app/src/lib/i18n/dictionaries/en.json | 6 +- apps/app/src/lib/i18n/dictionaries/es.json | 6 +- apps/app/src/lib/i18n/dictionaries/fr.json | 6 +- apps/app/src/lib/i18n/dictionaries/pt.json | 6 +- 6 files changed, 109 insertions(+), 71 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index 59d0357af..335e6e52d 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -5,11 +5,12 @@ import type { RubricTemplateSchema } from '@op/common/client'; import { useDebouncedCallback } from '@op/hooks'; import { Accordion } from '@op/ui/Accordion'; import { Button } from '@op/ui/Button'; +import { EmptyState } from '@op/ui/EmptyState'; import { Header2 } from '@op/ui/Header'; import type { Key } from '@op/ui/RAC'; import { Sortable } from '@op/ui/Sortable'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { LuPlus } from 'react-icons/lu'; +import { LuLeaf, LuPlus } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -232,83 +233,100 @@ export function RubricEditorContent({
- {t('Rubric criteria')} + {t('Review Criteria')}
-

- {t('Define how reviewers will evaluate proposals')} -

-
{criteria.length === 0 ? ( -
-

{t('No criteria defined')}

+
+ }> +
+ + {t('No review criteria yet')} + + + {t( + 'Add criteria to help reviewers evaluate proposals consistently', + )} + + +
+
) : ( - - criterion.label} - className="gap-3" - renderDragPreview={(items) => { - const criterion = items[0]; - if (!criterion) { - return null; - } - return ; - }} - renderDropIndicator={RubricCriterionDropIndicator} - aria-label={t('Rubric criteria')} + <> + - {(criterion, controls) => { - const snapshotErrors = - criterionErrors.get(criterion.id) ?? []; - const liveErrors = getCriterionErrors(criterion); - const displayedErrors = snapshotErrors.filter((e) => - liveErrors.includes(e), - ); - - return ( - - ); - }} - - + criterion.label} + className="gap-3" + renderDragPreview={(items) => { + const criterion = items[0]; + if (!criterion) { + return null; + } + return ; + }} + renderDropIndicator={RubricCriterionDropIndicator} + aria-label={t('Rubric criteria')} + > + {(criterion, controls) => { + const snapshotErrors = + criterionErrors.get(criterion.id) ?? []; + const liveErrors = getCriterionErrors(criterion); + const displayedErrors = snapshotErrors.filter((e) => + liveErrors.includes(e), + ); + + return ( + + ); + }} + + + + + )} - -
diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 7ccd4ce63..ccbb3fa60 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -902,5 +902,9 @@ "Scored": "স্কোরযুক্ত", "Select from custom options": "কাস্টম বিকল্প থেকে নির্বাচন করুন", "Simple yes or no answer": "সহজ হ্যাঁ বা না উত্তর", - "Yes / No": "হ্যাঁ / না" + "Yes / No": "হ্যাঁ / না", + "Review Criteria": "পর্যালোচনার মানদণ্ড", + "No review criteria yet": "এখনো কোনো পর্যালোচনার মানদণ্ড নেই", + "Add criteria to help reviewers evaluate proposals consistently": "পর্যালোচকদের ধারাবাহিকভাবে প্রস্তাব মূল্যায়নে সহায়তার জন্য মানদণ্ড যোগ করুন", + "Add your first criterion": "আপনার প্রথম মানদণ্ড যোগ করুন" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 86abd4c8a..a82a52385 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -895,5 +895,9 @@ "Scored": "Scored", "Select from custom options": "Select from custom options", "Simple yes or no answer": "Simple yes or no answer", - "Yes / No": "Yes / No" + "Yes / No": "Yes / No", + "Review Criteria": "Review Criteria", + "No review criteria yet": "No review criteria yet", + "Add criteria to help reviewers evaluate proposals consistently": "Add criteria to help reviewers evaluate proposals consistently", + "Add your first criterion": "Add your first criterion" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index fb0a50c46..7ab2ca004 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -895,5 +895,9 @@ "Scored": "Puntuado", "Select from custom options": "Seleccionar entre opciones personalizadas", "Simple yes or no answer": "Respuesta simple de sí o no", - "Yes / No": "Sí / No" + "Yes / No": "Sí / No", + "Review Criteria": "Criterios de revisión", + "No review criteria yet": "Aún no hay criterios de revisión", + "Add criteria to help reviewers evaluate proposals consistently": "Agregue criterios para ayudar a los revisores a evaluar propuestas de manera consistente", + "Add your first criterion": "Agregue su primer criterio" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index fb310138d..a6a1b41f4 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -895,5 +895,9 @@ "Scored": "Noté", "Select from custom options": "Sélectionner parmi des options personnalisées", "Simple yes or no answer": "Réponse simple oui ou non", - "Yes / No": "Oui / Non" + "Yes / No": "Oui / Non", + "Review Criteria": "Critères d'évaluation", + "No review criteria yet": "Pas encore de critères d'évaluation", + "Add criteria to help reviewers evaluate proposals consistently": "Ajoutez des critères pour aider les évaluateurs à évaluer les propositions de manière cohérente", + "Add your first criterion": "Ajoutez votre premier critère" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 9de09cd86..a98b27692 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -895,5 +895,9 @@ "Scored": "Pontuado", "Select from custom options": "Selecionar entre opções personalizadas", "Simple yes or no answer": "Resposta simples de sim ou não", - "Yes / No": "Sim / Não" + "Yes / No": "Sim / Não", + "Review Criteria": "Critérios de avaliação", + "No review criteria yet": "Ainda não há critérios de avaliação", + "Add criteria to help reviewers evaluate proposals consistently": "Adicione critérios para ajudar os avaliadores a avaliar propostas de forma consistente", + "Add your first criterion": "Adicione seu primeiro critério" } From 6dd96fe5fc507dbad9e2ac277858faad65b2f9bd Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 14:40:23 -0500 Subject: [PATCH 05/38] Fix rubric editor saving on initial page load Only trigger auto-save after a user-initiated edit, not on mount. Use a hasUserEditedRef guard so the debounced save effect doesn't fire from dependency reference changes or initial template hydration. --- .../rubric/RubricEditorContent.tsx | 91 ++++++++++++------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index 335e6e52d..4caf72e0c 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -69,6 +69,7 @@ export function RubricEditorContent({ const [template, setTemplate] = useState(initialTemplate); const isInitialLoadRef = useRef(true); + const hasUserEditedRef = useRef(false); // Validation: "show on blur, clear on change" const [criterionErrors, setCriterionErrors] = useState>( @@ -120,12 +121,17 @@ export function RubricEditorContent({ ); debouncedSaveRef.current = () => debouncedSave.isPending(); - // Trigger debounced save when template changes (skip initial load) + // Trigger debounced save when template changes. + // Only saves after a user-initiated edit — not on initial load or when + // the debounced callback reference changes. useEffect(() => { if (isInitialLoadRef.current) { isInitialLoadRef.current = false; return; } + if (!hasUserEditedRef.current) { + return; + } setSaveStatus(decisionProfileId, 'saving'); debouncedSave(template); @@ -133,86 +139,101 @@ export function RubricEditorContent({ // --- Handlers --- + /** Mark as user-edited and update template state. */ + const editTemplate = useCallback( + (updater: (prev: RubricTemplateSchema) => RubricTemplateSchema) => { + hasUserEditedRef.current = true; + setTemplate(updater); + }, + [], + ); + const handleAddCriterion = useCallback(() => { const criterionId = crypto.randomUUID().slice(0, 8); const label = t('New criterion'); - setTemplate((prev) => addCriterion(prev, criterionId, 'scored', label)); + editTemplate((prev) => addCriterion(prev, criterionId, 'scored', label)); setExpandedKeys((prev) => new Set([...prev, criterionId])); - }, [t]); - - const handleRemoveCriterion = useCallback((criterionId: string) => { - setTemplate((prev) => removeCriterion(prev, criterionId)); - setCriterionErrors((prev) => { - const next = new Map(prev); - next.delete(criterionId); - return next; - }); - }, []); - - const handleReorderCriteria = useCallback((newItems: CriterionView[]) => { - setTemplate((prev) => - reorderCriteria( - prev, - newItems.map((item) => item.id), - ), - ); - }, []); + }, [t, editTemplate]); + + const handleRemoveCriterion = useCallback( + (criterionId: string) => { + editTemplate((prev) => removeCriterion(prev, criterionId)); + setCriterionErrors((prev) => { + const next = new Map(prev); + next.delete(criterionId); + return next; + }); + }, + [editTemplate], + ); + + const handleReorderCriteria = useCallback( + (newItems: CriterionView[]) => { + editTemplate((prev) => + reorderCriteria( + prev, + newItems.map((item) => item.id), + ), + ); + }, + [editTemplate], + ); const handleUpdateLabel = useCallback( (criterionId: string, label: string) => { - setTemplate((prev) => updateCriterionLabel(prev, criterionId, label)); + editTemplate((prev) => updateCriterionLabel(prev, criterionId, label)); }, - [], + [editTemplate], ); const handleUpdateDescription = useCallback( (criterionId: string, description: string) => { - setTemplate((prev) => + editTemplate((prev) => updateCriterionDescription(prev, criterionId, description || undefined), ); }, - [], + [editTemplate], ); const handleUpdateRequired = useCallback( (criterionId: string, required: boolean) => { - setTemplate((prev) => setCriterionRequired(prev, criterionId, required)); + editTemplate((prev) => setCriterionRequired(prev, criterionId, required)); }, - [], + [editTemplate], ); const handleChangeType = useCallback( (criterionId: string, newType: RubricCriterionType) => { - setTemplate((prev) => changeCriterionType(prev, criterionId, newType)); + editTemplate((prev) => changeCriterionType(prev, criterionId, newType)); }, - [], + [editTemplate], ); const handleUpdateJsonSchema = useCallback( (criterionId: string, updates: Record) => { - setTemplate((prev) => + editTemplate((prev) => updateCriterionJsonSchema(prev, criterionId, updates), ); }, - [], + [editTemplate], ); const handleUpdateMaxPoints = useCallback( (criterionId: string, maxPoints: number) => { - setTemplate((prev) => + editTemplate((prev) => updateScoredMaxPoints(prev, criterionId, maxPoints), ); }, - [], + [editTemplate], ); const handleUpdateScoreLabel = useCallback( (criterionId: string, scoreIndex: number, label: string) => { - setTemplate((prev) => + editTemplate((prev) => updateScoreLabel(prev, criterionId, scoreIndex, label), ); }, - [], + [editTemplate], ); const handleCriterionBlur = useCallback( From f565771db08a3dc475d66344134fa851307227f8 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 14:41:28 -0500 Subject: [PATCH 06/38] Revert "Fix rubric editor saving on initial page load" This reverts commit ddf1466de595a96458f5d1267f967a6709d1d49d. --- .../rubric/RubricEditorContent.tsx | 91 +++++++------------ 1 file changed, 35 insertions(+), 56 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index 4caf72e0c..335e6e52d 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -69,7 +69,6 @@ export function RubricEditorContent({ const [template, setTemplate] = useState(initialTemplate); const isInitialLoadRef = useRef(true); - const hasUserEditedRef = useRef(false); // Validation: "show on blur, clear on change" const [criterionErrors, setCriterionErrors] = useState>( @@ -121,17 +120,12 @@ export function RubricEditorContent({ ); debouncedSaveRef.current = () => debouncedSave.isPending(); - // Trigger debounced save when template changes. - // Only saves after a user-initiated edit — not on initial load or when - // the debounced callback reference changes. + // Trigger debounced save when template changes (skip initial load) useEffect(() => { if (isInitialLoadRef.current) { isInitialLoadRef.current = false; return; } - if (!hasUserEditedRef.current) { - return; - } setSaveStatus(decisionProfileId, 'saving'); debouncedSave(template); @@ -139,101 +133,86 @@ export function RubricEditorContent({ // --- Handlers --- - /** Mark as user-edited and update template state. */ - const editTemplate = useCallback( - (updater: (prev: RubricTemplateSchema) => RubricTemplateSchema) => { - hasUserEditedRef.current = true; - setTemplate(updater); - }, - [], - ); - const handleAddCriterion = useCallback(() => { const criterionId = crypto.randomUUID().slice(0, 8); const label = t('New criterion'); - editTemplate((prev) => addCriterion(prev, criterionId, 'scored', label)); + setTemplate((prev) => addCriterion(prev, criterionId, 'scored', label)); setExpandedKeys((prev) => new Set([...prev, criterionId])); - }, [t, editTemplate]); - - const handleRemoveCriterion = useCallback( - (criterionId: string) => { - editTemplate((prev) => removeCriterion(prev, criterionId)); - setCriterionErrors((prev) => { - const next = new Map(prev); - next.delete(criterionId); - return next; - }); - }, - [editTemplate], - ); - - const handleReorderCriteria = useCallback( - (newItems: CriterionView[]) => { - editTemplate((prev) => - reorderCriteria( - prev, - newItems.map((item) => item.id), - ), - ); - }, - [editTemplate], - ); + }, [t]); + + const handleRemoveCriterion = useCallback((criterionId: string) => { + setTemplate((prev) => removeCriterion(prev, criterionId)); + setCriterionErrors((prev) => { + const next = new Map(prev); + next.delete(criterionId); + return next; + }); + }, []); + + const handleReorderCriteria = useCallback((newItems: CriterionView[]) => { + setTemplate((prev) => + reorderCriteria( + prev, + newItems.map((item) => item.id), + ), + ); + }, []); const handleUpdateLabel = useCallback( (criterionId: string, label: string) => { - editTemplate((prev) => updateCriterionLabel(prev, criterionId, label)); + setTemplate((prev) => updateCriterionLabel(prev, criterionId, label)); }, - [editTemplate], + [], ); const handleUpdateDescription = useCallback( (criterionId: string, description: string) => { - editTemplate((prev) => + setTemplate((prev) => updateCriterionDescription(prev, criterionId, description || undefined), ); }, - [editTemplate], + [], ); const handleUpdateRequired = useCallback( (criterionId: string, required: boolean) => { - editTemplate((prev) => setCriterionRequired(prev, criterionId, required)); + setTemplate((prev) => setCriterionRequired(prev, criterionId, required)); }, - [editTemplate], + [], ); const handleChangeType = useCallback( (criterionId: string, newType: RubricCriterionType) => { - editTemplate((prev) => changeCriterionType(prev, criterionId, newType)); + setTemplate((prev) => changeCriterionType(prev, criterionId, newType)); }, - [editTemplate], + [], ); const handleUpdateJsonSchema = useCallback( (criterionId: string, updates: Record) => { - editTemplate((prev) => + setTemplate((prev) => updateCriterionJsonSchema(prev, criterionId, updates), ); }, - [editTemplate], + [], ); const handleUpdateMaxPoints = useCallback( (criterionId: string, maxPoints: number) => { - editTemplate((prev) => + setTemplate((prev) => updateScoredMaxPoints(prev, criterionId, maxPoints), ); }, - [editTemplate], + [], ); const handleUpdateScoreLabel = useCallback( (criterionId: string, scoreIndex: number, label: string) => { - editTemplate((prev) => + setTemplate((prev) => updateScoreLabel(prev, criterionId, scoreIndex, label), ); }, - [editTemplate], + [], ); const handleCriterionBlur = useCallback( From 532e4393795fdf86dc9602c111786be57fd69585 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 14:46:20 -0500 Subject: [PATCH 07/38] Remove coming soon tag for users with rubric access --- .../decisions/ProcessBuilder/ProcessBuilderHeader.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx index a58b4e1b2..7ddc35177 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useFeatureFlag } from '@/hooks/useFeatureFlag'; import { trpc } from '@op/api/client'; import { ProcessStatus } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; @@ -87,6 +88,7 @@ const ProcessBuilderHeaderContent = ({ slug?: string; }) => { const t = useTranslations(); + const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); const router = useRouter(); const navigationConfig = useNavigationConfig(instanceId); const { visibleSteps, currentStep, setStep } = @@ -239,7 +241,9 @@ const ProcessBuilderHeaderContent = ({ className="flex h-full cursor-pointer items-center gap-2" > {t(step.labelKey)} - {step.id === 'rubric' && } + {step.id === 'rubric' && !rubricBuilderEnabled && ( + + )} ))} From 559f6361ee34c84252b276d8f23ee734addbe05b Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 15:52:31 -0500 Subject: [PATCH 08/38] Rewrite criterion card with Accordion primitives and remove dropdown type - Remove 'dropdown' criterion type, leaving only scored/yes_no/long_text - Rewrite RubricCriterionCard to use Accordion primitives directly (not FieldConfigCard) with static 'Criterion N' header, separate name/description fields, and type radio - Wrap criteria in AccordionItem in RubricEditorContent (matching PhasesSectionContent pattern) - Update rubricCriterionRegistry with new labels (Rating Scale, Yes/No, Text response only) - Clean up unused handlers and imports from RubricEditorContent - Add 15 new translation keys to all 5 language files --- .../rubric/RubricCriterionCard.tsx | 435 +++++------------- .../rubric/RubricEditorContent.tsx | 78 ++-- .../rubric/rubricCriterionRegistry.tsx | 39 +- .../components/decisions/rubricTemplate.ts | 57 +-- apps/app/src/lib/i18n/dictionaries/bn.json | 18 +- apps/app/src/lib/i18n/dictionaries/en.json | 18 +- apps/app/src/lib/i18n/dictionaries/es.json | 18 +- apps/app/src/lib/i18n/dictionaries/fr.json | 18 +- apps/app/src/lib/i18n/dictionaries/pt.json | 18 +- 9 files changed, 249 insertions(+), 450 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx index 4c48d9609..cd99d6c6c 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx @@ -1,20 +1,18 @@ 'use client'; -import type { XFormatPropertySchema } from '@op/common/client'; -import { Button } from '@op/ui/Button'; import { - FieldConfigCard, - FieldConfigCardDragPreview, -} from '@op/ui/FieldConfigCard'; + AccordionContent, + AccordionHeader, + AccordionIndicator, + AccordionTrigger, +} from '@op/ui/Accordion'; +import { Button } from '@op/ui/Button'; import { NumberField } from '@op/ui/NumberField'; import { Radio, RadioGroup } from '@op/ui/RadioGroup'; -import { DragHandle, Sortable } from '@op/ui/Sortable'; +import { DragHandle } from '@op/ui/Sortable'; import type { SortableItemControls } from '@op/ui/Sortable'; import { TextField } from '@op/ui/TextField'; -import { ToggleButton } from '@op/ui/ToggleButton'; -import { Tooltip, TooltipTrigger } from '@op/ui/Tooltip'; -import { useEffect, useRef, useState } from 'react'; -import { LuGripVertical, LuPlus, LuX } from 'react-icons/lu'; +import { LuGripVertical, LuTrash2 } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -25,7 +23,6 @@ import type { import { CRITERION_TYPES, CRITERION_TYPE_REGISTRY, - getCriterionIcon, } from './rubricCriterionRegistry'; // --------------------------------------------------------------------------- @@ -34,18 +31,14 @@ import { interface RubricCriterionCardProps { criterion: CriterionView; + /** 1-based display index for the header (e.g. "Criterion 1") */ + index: number; errors?: string[]; controls?: SortableItemControls; onRemove?: (criterionId: string) => void; - onBlur?: (criterionId: string) => void; onUpdateLabel?: (criterionId: string, label: string) => void; onUpdateDescription?: (criterionId: string, description: string) => void; - onUpdateRequired?: (criterionId: string, isRequired: boolean) => void; onChangeType?: (criterionId: string, newType: RubricCriterionType) => void; - onUpdateJsonSchema?: ( - criterionId: string, - updates: Partial, - ) => void; onUpdateMaxPoints?: (criterionId: string, maxPoints: number) => void; onUpdateScoreLabel?: ( criterionId: string, @@ -59,144 +52,127 @@ interface RubricCriterionCardProps { // --------------------------------------------------------------------------- /** - * A collapsible card representing a single rubric criterion in the builder. - * Uses FieldConfigCard with `collapsible` prop for accordion behaviour. + * A collapsible accordion card for a single rubric criterion. + * + * Built directly with Accordion primitives (not FieldConfigCard) to match + * the mockup: static "Criterion N" header, separate name/description fields + * in the body, and a criterion type radio selector. * - * Collapsed: drag handle + chevron + icon + label + badge + remove - * Expanded: description, criterion type radio, type-specific config, required toggle + * Must be rendered inside an `` which is inside an ``. */ export function RubricCriterionCard({ criterion, + index, errors = [], controls, onRemove, - onBlur, onUpdateLabel, onUpdateDescription, - onUpdateRequired, onChangeType, - onUpdateJsonSchema, onUpdateMaxPoints, onUpdateScoreLabel, }: RubricCriterionCardProps) { const t = useTranslations(); - const cardRef = useRef(null); - - const Icon = getCriterionIcon(criterion.criterionType); - - const handleBlur = (e: React.FocusEvent) => { - if (cardRef.current && !cardRef.current.contains(e.relatedTarget as Node)) { - onBlur?.(criterion.id); - } - }; - - // Badge for the header - const headerBadge = (() => { - switch (criterion.criterionType) { - case 'scored': - return ( - - {criterion.maxPoints} {t('pts')} - - ); - case 'yes_no': - return ( - - {t('Yes / No')} - - ); - default: - return null; - } - })(); return ( -
- + {/* Header: drag handle + chevron + "Criterion N" + delete button */} + + {controls && ( + )} - label={criterion.label} - onLabelChange={(newLabel) => onUpdateLabel?.(criterion.id, newLabel)} - labelInputAriaLabel={t('Criterion label')} - description={criterion.description} - onDescriptionChange={(desc) => - onUpdateDescription?.(criterion.id, desc) - } - descriptionLabel={t('Description')} - descriptionPlaceholder={t( - 'Provide guidance for reviewers on how to evaluate this criterion...', + + + + + {t('Criterion {number}', { number: index })} + + {onRemove && ( + )} - onRemove={onRemove ? () => onRemove(criterion.id) : undefined} - removeAriaLabel={t('Remove criterion')} - dragHandleAriaLabel={t('Drag to reorder {field}', { - field: criterion.label, - })} - controls={controls} - headerExtra={headerBadge} - className={errors.length > 0 ? 'border-functional-red' : undefined} - > - {/* Criterion type selector */} -
- onChangeType?.(criterion.id, newType)} + + + {/* Collapsible body */} + +
+ {/* Criterion name */} + onUpdateLabel?.(criterion.id, value)} + inputProps={{ + placeholder: t('e.g., Goal Alignment'), + }} + description={t( + 'Add a short, clear name for this evaluation criterion', + )} /> -
- {/* Type-specific configuration */} - {criterion.criterionType === 'scored' && ( -
- - onUpdateMaxPoints?.(criterion.id, max) - } - onUpdateScoreLabel={(index, label) => - onUpdateScoreLabel?.(criterion.id, index, label) - } - /> -
- )} - - {criterion.criterionType === 'dropdown' && ( -
- - onUpdateJsonSchema?.(criterion.id, updates) - } - /> -
- )} + {/* Description */} + onUpdateDescription?.(criterion.id, value)} + textareaProps={{ + placeholder: t( + "What should reviewers evaluate? Be specific about what you're looking for.", + ), + className: 'min-h-24 resize-none', + }} + description={t('Help reviewers understand what to assess')} + /> - {/* Validation errors */} - {errors.length > 0 && ( -
- {errors.map((error) => ( -

- {t(error)} -

- ))} -
- )} +
- {/* Required toggle */} -
- {t('Required?')} - - onUpdateRequired?.(criterion.id, isSelected) - } - aria-label={t('Required')} + {/* Criterion type radio selector */} + onChangeType?.(criterion.id, newType)} /> + + {/* Type-specific configuration */} + {criterion.criterionType === 'scored' && ( + <> +
+ + onUpdateMaxPoints?.(criterion.id, max) + } + onUpdateScoreLabel={(scoreIndex, label) => + onUpdateScoreLabel?.(criterion.id, scoreIndex, label) + } + /> + + )} + + {/* Validation errors */} + {errors.length > 0 && ( +
+ {errors.map((error) => ( +

+ {t(error)} +

+ ))} +
+ )}
- -
+ + ); } @@ -215,26 +191,26 @@ function CriterionTypeSelector({ return ( onChange(newValue as RubricCriterionType)} orientation="vertical" > {CRITERION_TYPES.map((type) => { const entry = CRITERION_TYPE_REGISTRY[type]; - const TypeIcon = entry.icon; return ( -
- - {t(entry.labelKey)} - - {t(entry.descriptionKey)} +
+ + {t(entry.labelKey)} +

+ {t(entry.descriptionKey)} +

); @@ -274,17 +250,17 @@ function ScoredCriterionConfig({

{t('Score labels')}

- {criterion.scoreLabels.map((label, index) => ( -
+ {criterion.scoreLabels.map((label, scoreIndex) => ( +
- {index + 1} + {scoreIndex + 1} onUpdateScoreLabel(index, value)} + onChange={(value) => onUpdateScoreLabel(scoreIndex, value)} inputProps={{ placeholder: t('Label for score {number}', { - number: index + 1, + number: scoreIndex + 1, }), }} className="w-full" @@ -297,178 +273,21 @@ function ScoredCriterionConfig({ } // --------------------------------------------------------------------------- -// Dropdown criterion config (custom options list) -// Reuses the same sortable options pattern as FieldConfigDropdown +// Drag preview // --------------------------------------------------------------------------- -interface DropdownOption { - id: string; - value: string; -} - -function DropdownCriterionConfig({ - criterion, - onUpdateJsonSchema, -}: { - criterion: CriterionView; - onUpdateJsonSchema: (updates: Partial) => void; -}) { +export function RubricCriterionDragPreview({ index }: { index: number }) { const t = useTranslations(); - const containerRef = useRef(null); - const shouldFocusNewRef = useRef(false); - - const [options, setOptions] = useState(() => - criterion.options.map((o) => ({ ...o, id: crypto.randomUUID() })), - ); - - const updateOptions = (next: DropdownOption[]) => { - setOptions(next); - const oneOfValues = next.map((o) => ({ - const: o.value, - title: o.value, - })); - onUpdateJsonSchema({ oneOf: oneOfValues }); - }; - - // Focus the last input when a new option is added - useEffect(() => { - if (shouldFocusNewRef.current && containerRef.current) { - const inputs = containerRef.current.querySelectorAll( - 'input[type="text"]', - ) as NodeListOf; - const lastInput = inputs[inputs.length - 1]; - lastInput?.focus(); - shouldFocusNewRef.current = false; - } - }, [options.length]); - - const handleAddOption = () => { - shouldFocusNewRef.current = true; - updateOptions([...options, { id: crypto.randomUUID(), value: '' }]); - }; - - const handleUpdateOption = (id: string, value: string) => { - updateOptions( - options.map((opt) => (opt.id === id ? { ...opt, value } : opt)), - ); - }; - - const handleRemoveOption = (id: string) => { - updateOptions(options.filter((opt) => opt.id !== id)); - }; - - const handleKeyDown = (e: React.KeyboardEvent, option: DropdownOption) => { - if (e.key === 'Enter') { - e.preventDefault(); - const isLastOption = options[options.length - 1]?.id === option.id; - if (isLastOption && option.value.trim()) { - handleAddOption(); - } - } - }; - return ( -
-

{t('Options')}

- - item.value || t('Option')} - renderDragPreview={(items) => { - const item = items[0]; - if (!item) { - return null; - } - return ( -
- - - {item.value || t('Option')} - -
- ); - }} - className="gap-2" - aria-label={t('Dropdown options')} - > - {(option, controls) => { - const index = options.findIndex((o) => o.id === option.id); - return ( -
- - handleUpdateOption(option.id, value)} - onKeyDown={(e) => handleKeyDown(e, option)} - inputProps={{ - placeholder: t('Option {number}', { number: index + 1 }), - }} - className="w-full" - /> - 2}> - - {t('At least two options are required')} - -
- ); - }} -
- - +
+ + + {t('Criterion {number}', { number: index })} +
); } -// --------------------------------------------------------------------------- -// Drag preview -// --------------------------------------------------------------------------- - -export function RubricCriterionDragPreview({ - criterion, -}: { - criterion: CriterionView; -}) { - const Icon = getCriterionIcon(criterion.criterionType); - return ; -} - export function RubricCriterionDropIndicator() { return (
diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index 335e6e52d..9f108f13a 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -3,12 +3,13 @@ import { trpc } from '@op/api/client'; import type { RubricTemplateSchema } from '@op/common/client'; import { useDebouncedCallback } from '@op/hooks'; -import { Accordion } from '@op/ui/Accordion'; +import { Accordion, AccordionItem } from '@op/ui/Accordion'; import { Button } from '@op/ui/Button'; import { EmptyState } from '@op/ui/EmptyState'; import { Header2 } from '@op/ui/Header'; import type { Key } from '@op/ui/RAC'; import { Sortable } from '@op/ui/Sortable'; +import { cn } from '@op/ui/utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LuLeaf, LuPlus } from 'react-icons/lu'; @@ -23,13 +24,10 @@ import { changeCriterionType, createEmptyRubricTemplate, getCriteria, - getCriterion, getCriterionErrors, removeCriterion, reorderCriteria, - setCriterionRequired, updateCriterionDescription, - updateCriterionJsonSchema, updateCriterionLabel, updateScoreLabel, updateScoredMaxPoints, @@ -174,13 +172,6 @@ export function RubricEditorContent({ [], ); - const handleUpdateRequired = useCallback( - (criterionId: string, required: boolean) => { - setTemplate((prev) => setCriterionRequired(prev, criterionId, required)); - }, - [], - ); - const handleChangeType = useCallback( (criterionId: string, newType: RubricCriterionType) => { setTemplate((prev) => changeCriterionType(prev, criterionId, newType)); @@ -188,15 +179,6 @@ export function RubricEditorContent({ [], ); - const handleUpdateJsonSchema = useCallback( - (criterionId: string, updates: Record) => { - setTemplate((prev) => - updateCriterionJsonSchema(prev, criterionId, updates), - ); - }, - [], - ); - const handleUpdateMaxPoints = useCallback( (criterionId: string, maxPoints: number) => { setTemplate((prev) => @@ -215,18 +197,6 @@ export function RubricEditorContent({ [], ); - const handleCriterionBlur = useCallback( - (criterionId: string) => { - const criterion = getCriterion(template, criterionId); - if (criterion) { - setCriterionErrors((prev) => - new Map(prev).set(criterionId, getCriterionErrors(criterion)), - ); - } - }, - [template], - ); - return (
@@ -279,16 +249,19 @@ export function RubricEditorContent({ getItemLabel={(criterion) => criterion.label} className="gap-3" renderDragPreview={(items) => { - const criterion = items[0]; - if (!criterion) { + const item = items[0]; + if (!item) { return null; } - return ; + const idx = criteria.findIndex((c) => c.id === item.id) + 1; + return ; }} renderDropIndicator={RubricCriterionDropIndicator} aria-label={t('Rubric criteria')} > {(criterion, controls) => { + const idx = + criteria.findIndex((c) => c.id === criterion.id) + 1; const snapshotErrors = criterionErrors.get(criterion.id) ?? []; const liveErrors = getCriterionErrors(criterion); @@ -297,21 +270,26 @@ export function RubricEditorContent({ ); return ( - + + + ); }} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx index ebcd4ce7e..d8a060254 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx @@ -1,18 +1,9 @@ -import type { IconType } from 'react-icons'; -import { - LuChevronDown, - LuHash, - LuLetterText, - LuToggleRight, -} from 'react-icons/lu'; - import type { RubricCriterionType } from '../../../../decisions/rubricTemplate'; /** * Display metadata for each rubric criterion type. */ interface CriterionTypeRegistryEntry { - icon: IconType; /** Translation key for the type label */ labelKey: string; /** Translation key for a short description shown in the radio selector */ @@ -24,24 +15,17 @@ export const CRITERION_TYPE_REGISTRY: Record< CriterionTypeRegistryEntry > = { scored: { - icon: LuHash, - labelKey: 'Scored', - descriptionKey: 'Rate on a numeric scale with points', + labelKey: 'Rating Scale', + descriptionKey: + 'Reviewers select a number with descriptions for each point value', }, yes_no: { - icon: LuToggleRight, - labelKey: 'Yes / No', - descriptionKey: 'Simple yes or no answer', - }, - dropdown: { - icon: LuChevronDown, - labelKey: 'Dropdown', - descriptionKey: 'Select from custom options', + labelKey: 'Yes/No', + descriptionKey: 'Simple binary assessment', }, long_text: { - icon: LuLetterText, - labelKey: 'Long text', - descriptionKey: 'Open-ended written feedback', + labelKey: 'Text response only', + descriptionKey: 'No score, just written feedback', }, }; @@ -51,14 +35,5 @@ export const CRITERION_TYPE_REGISTRY: Record< export const CRITERION_TYPES: RubricCriterionType[] = [ 'scored', 'yes_no', - 'dropdown', 'long_text', ]; - -export function getCriterionIcon(type: RubricCriterionType): IconType { - return CRITERION_TYPE_REGISTRY[type].icon; -} - -export function getCriterionLabelKey(type: RubricCriterionType): string { - return CRITERION_TYPE_REGISTRY[type].labelKey; -} diff --git a/apps/app/src/components/decisions/rubricTemplate.ts b/apps/app/src/components/decisions/rubricTemplate.ts index 696f1b679..9405979c7 100644 --- a/apps/app/src/components/decisions/rubricTemplate.ts +++ b/apps/app/src/components/decisions/rubricTemplate.ts @@ -6,7 +6,7 @@ * `x-format` on each property (consumed by the renderer's rubric field logic). * * Mirrors the architecture of `proposalTemplate.ts` but tailored to rubric - * criteria: scored dropdowns, yes/no, custom dropdowns, and long text. + * criteria: rating scale, yes/no, and text response. */ import type { RubricTemplateSchema, @@ -19,11 +19,7 @@ export type { RubricTemplateSchema }; // Criterion types // --------------------------------------------------------------------------- -export type RubricCriterionType = - | 'scored' - | 'yes_no' - | 'dropdown' - | 'long_text'; +export type RubricCriterionType = 'scored' | 'yes_no' | 'long_text'; /** * Flat read-only view of a single rubric criterion, derived from the template. @@ -40,8 +36,6 @@ export interface CriterionView { maxPoints?: number; /** Labels for each score level (index 0 = score 1). Scored criteria only. */ scoreLabels: string[]; - /** Options for dropdown criteria. */ - options: { id: string; value: string }[]; } // --------------------------------------------------------------------------- @@ -88,15 +82,6 @@ export function createCriterionJsonSchema( { const: 'no', title: 'No' }, ], }; - case 'dropdown': - return { - type: 'string', - 'x-format': 'dropdown', - oneOf: [ - { const: 'Option 1', title: 'Option 1' }, - { const: 'Option 2', title: 'Option 2' }, - ], - }; case 'long_text': return { type: 'string', @@ -143,11 +128,6 @@ export function inferCriterionType( return 'yes_no'; } } - - // Generic dropdown - if (schema.type === 'string') { - return 'dropdown'; - } } return undefined; @@ -249,29 +229,6 @@ export function getCriterionScoreLabels( .map((e) => e.title); } -export function getCriterionOptions( - template: RubricTemplateSchema, - criterionId: string, -): { id: string; value: string }[] { - const schema = getCriterionSchema(template, criterionId); - if (!schema || schema.type !== 'string' || !Array.isArray(schema.oneOf)) { - return []; - } - return schema.oneOf - .filter( - (e): e is { const: string; title: string } => - typeof e === 'object' && - e !== null && - 'const' in e && - 'title' in e && - typeof (e as { const: unknown }).const === 'string', - ) - .map((e, i) => ({ - id: `${criterionId}-opt-${i}`, - value: e.title, - })); -} - // --------------------------------------------------------------------------- // Composite readers // --------------------------------------------------------------------------- @@ -293,7 +250,6 @@ export function getCriterion( required: isCriterionRequired(template, criterionId), maxPoints: getCriterionMaxPoints(template, criterionId), scoreLabels: getCriterionScoreLabels(template, criterionId), - options: getCriterionOptions(template, criterionId), }; } @@ -324,15 +280,6 @@ export function getCriterionErrors(criterion: CriterionView): string[] { errors.push('Criterion label is required'); } - if (criterion.criterionType === 'dropdown') { - if (criterion.options.length < 2) { - errors.push('At least two options are required'); - } - if (criterion.options.some((o) => !o.value.trim())) { - errors.push('Options cannot be empty'); - } - } - if (criterion.criterionType === 'scored') { if (criterion.scoreLabels.some((l) => !l.trim())) { errors.push('Score labels cannot be empty'); diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index ccbb3fa60..2e0ee25f0 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -906,5 +906,21 @@ "Review Criteria": "পর্যালোচনার মানদণ্ড", "No review criteria yet": "এখনো কোনো পর্যালোচনার মানদণ্ড নেই", "Add criteria to help reviewers evaluate proposals consistently": "পর্যালোচকদের ধারাবাহিকভাবে প্রস্তাব মূল্যায়নে সহায়তার জন্য মানদণ্ড যোগ করুন", - "Add your first criterion": "আপনার প্রথম মানদণ্ড যোগ করুন" + "Add your first criterion": "আপনার প্রথম মানদণ্ড যোগ করুন", + "Criterion {number}": "মানদণ্ড {number}", + "Criterion name": "মানদণ্ডের নাম", + "Criterion label is required": "মানদণ্ডের লেবেল আবশ্যক", + "Drag to reorder criterion": "মানদণ্ড পুনর্বিন্যাস করতে টানুন", + "e.g., Goal Alignment": "যেমন, লক্ষ্য সারিবদ্ধকরণ", + "Add a short, clear name for this evaluation criterion": "এই মূল্যায়ন মানদণ্ডের জন্য একটি সংক্ষিপ্ত, স্পষ্ট নাম যোগ করুন", + "What should reviewers evaluate? Be specific about what you're looking for.": "পর্যালোচকদের কী মূল্যায়ন করা উচিত? আপনি কী খুঁজছেন সে সম্পর্কে সুনির্দিষ্ট হন।", + "Help reviewers understand what to assess": "পর্যালোচকদের কী মূল্যায়ন করতে হবে তা বুঝতে সাহায্য করুন", + "How should reviewers score this?": "পর্যালোচকরা কীভাবে এটি স্কোর করবেন?", + "Rating Scale": "রেটিং স্কেল", + "Reviewers select a number with descriptions for each point value": "পর্যালোচকরা প্রতিটি পয়েন্ট মানের বিবরণ সহ একটি সংখ্যা নির্বাচন করেন", + "Simple binary assessment": "সহজ বাইনারি মূল্যায়ন", + "Text response only": "শুধুমাত্র পাঠ্য প্রতিক্রিয়া", + "No score, just written feedback": "কোনো স্কোর নেই, শুধু লিখিত প্রতিক্রিয়া", + "Score labels cannot be empty": "স্কোর লেবেল খালি রাখা যাবে না", + "Start typing...": "টাইপ শুরু করুন..." } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index a82a52385..f4fe3f98c 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -899,5 +899,21 @@ "Review Criteria": "Review Criteria", "No review criteria yet": "No review criteria yet", "Add criteria to help reviewers evaluate proposals consistently": "Add criteria to help reviewers evaluate proposals consistently", - "Add your first criterion": "Add your first criterion" + "Add your first criterion": "Add your first criterion", + "Criterion {number}": "Criterion {number}", + "Criterion name": "Criterion name", + "Criterion label is required": "Criterion label is required", + "Drag to reorder criterion": "Drag to reorder criterion", + "e.g., Goal Alignment": "e.g., Goal Alignment", + "Add a short, clear name for this evaluation criterion": "Add a short, clear name for this evaluation criterion", + "What should reviewers evaluate? Be specific about what you're looking for.": "What should reviewers evaluate? Be specific about what you're looking for.", + "Help reviewers understand what to assess": "Help reviewers understand what to assess", + "How should reviewers score this?": "How should reviewers score this?", + "Rating Scale": "Rating Scale", + "Reviewers select a number with descriptions for each point value": "Reviewers select a number with descriptions for each point value", + "Simple binary assessment": "Simple binary assessment", + "Text response only": "Text response only", + "No score, just written feedback": "No score, just written feedback", + "Score labels cannot be empty": "Score labels cannot be empty", + "Start typing...": "Start typing..." } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 7ab2ca004..d658a39bc 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -899,5 +899,21 @@ "Review Criteria": "Criterios de revisión", "No review criteria yet": "Aún no hay criterios de revisión", "Add criteria to help reviewers evaluate proposals consistently": "Agregue criterios para ayudar a los revisores a evaluar propuestas de manera consistente", - "Add your first criterion": "Agregue su primer criterio" + "Add your first criterion": "Agregue su primer criterio", + "Criterion {number}": "Criterio {number}", + "Criterion name": "Nombre del criterio", + "Criterion label is required": "La etiqueta del criterio es obligatoria", + "Drag to reorder criterion": "Arrastrar para reordenar criterio", + "e.g., Goal Alignment": "p. ej., Alineación con objetivos", + "Add a short, clear name for this evaluation criterion": "Agregue un nombre corto y claro para este criterio de evaluación", + "What should reviewers evaluate? Be specific about what you're looking for.": "¿Qué deben evaluar los revisores? Sea específico sobre lo que busca.", + "Help reviewers understand what to assess": "Ayude a los revisores a entender qué evaluar", + "How should reviewers score this?": "¿Cómo deben puntuar esto los revisores?", + "Rating Scale": "Escala de calificación", + "Reviewers select a number with descriptions for each point value": "Los revisores seleccionan un número con descripciones para cada valor de punto", + "Simple binary assessment": "Evaluación binaria simple", + "Text response only": "Solo respuesta de texto", + "No score, just written feedback": "Sin puntuación, solo retroalimentación escrita", + "Score labels cannot be empty": "Las etiquetas de puntuación no pueden estar vacías", + "Start typing...": "Comience a escribir..." } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index a6a1b41f4..6baf3f318 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -899,5 +899,21 @@ "Review Criteria": "Critères d'évaluation", "No review criteria yet": "Pas encore de critères d'évaluation", "Add criteria to help reviewers evaluate proposals consistently": "Ajoutez des critères pour aider les évaluateurs à évaluer les propositions de manière cohérente", - "Add your first criterion": "Ajoutez votre premier critère" + "Add your first criterion": "Ajoutez votre premier critère", + "Criterion {number}": "Critère {number}", + "Criterion name": "Nom du critère", + "Criterion label is required": "Le libellé du critère est obligatoire", + "Drag to reorder criterion": "Glisser pour réordonner le critère", + "e.g., Goal Alignment": "p. ex., Alignement des objectifs", + "Add a short, clear name for this evaluation criterion": "Ajoutez un nom court et clair pour ce critère d'évaluation", + "What should reviewers evaluate? Be specific about what you're looking for.": "Que doivent évaluer les évaluateurs ? Soyez précis sur ce que vous recherchez.", + "Help reviewers understand what to assess": "Aidez les évaluateurs à comprendre quoi évaluer", + "How should reviewers score this?": "Comment les évaluateurs doivent-ils noter ceci ?", + "Rating Scale": "Échelle de notation", + "Reviewers select a number with descriptions for each point value": "Les évaluateurs sélectionnent un nombre avec des descriptions pour chaque valeur de point", + "Simple binary assessment": "Évaluation binaire simple", + "Text response only": "Réponse textuelle uniquement", + "No score, just written feedback": "Pas de note, uniquement des commentaires écrits", + "Score labels cannot be empty": "Les libellés des notes ne peuvent pas être vides", + "Start typing...": "Commencez à écrire..." } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index a98b27692..62aa2bc8a 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -899,5 +899,21 @@ "Review Criteria": "Critérios de avaliação", "No review criteria yet": "Ainda não há critérios de avaliação", "Add criteria to help reviewers evaluate proposals consistently": "Adicione critérios para ajudar os avaliadores a avaliar propostas de forma consistente", - "Add your first criterion": "Adicione seu primeiro critério" + "Add your first criterion": "Adicione seu primeiro critério", + "Criterion {number}": "Critério {number}", + "Criterion name": "Nome do critério", + "Criterion label is required": "O rótulo do critério é obrigatório", + "Drag to reorder criterion": "Arrastar para reordenar critério", + "e.g., Goal Alignment": "p. ex., Alinhamento de objetivos", + "Add a short, clear name for this evaluation criterion": "Adicione um nome curto e claro para este critério de avaliação", + "What should reviewers evaluate? Be specific about what you're looking for.": "O que os avaliadores devem avaliar? Seja específico sobre o que procura.", + "Help reviewers understand what to assess": "Ajude os avaliadores a entender o que avaliar", + "How should reviewers score this?": "Como os avaliadores devem pontuar isto?", + "Rating Scale": "Escala de classificação", + "Reviewers select a number with descriptions for each point value": "Os avaliadores selecionam um número com descrições para cada valor de ponto", + "Simple binary assessment": "Avaliação binária simples", + "Text response only": "Apenas resposta de texto", + "No score, just written feedback": "Sem pontuação, apenas feedback escrito", + "Score labels cannot be empty": "Os rótulos das notas não podem estar vazios", + "Start typing...": "Comece a digitar..." } From 77844a73451716b47c3c2da8a87040e86a3dd492 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 16:03:10 -0500 Subject: [PATCH 09/38] Add confirmation modal when deleting a rubric criterion Follows the same pattern as PhasesSectionContent: clicking the trash button now opens a dismissable modal asking the user to confirm before the criterion is removed. Adds translation keys for the modal to all 5 language files. --- .../rubric/RubricEditorContent.tsx | 55 ++++++++++++++++++- apps/app/src/lib/i18n/dictionaries/bn.json | 4 +- apps/app/src/lib/i18n/dictionaries/en.json | 4 +- apps/app/src/lib/i18n/dictionaries/es.json | 4 +- apps/app/src/lib/i18n/dictionaries/fr.json | 4 +- apps/app/src/lib/i18n/dictionaries/pt.json | 4 +- 6 files changed, 67 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index 9f108f13a..9c76db70b 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -7,6 +7,7 @@ import { Accordion, AccordionItem } from '@op/ui/Accordion'; import { Button } from '@op/ui/Button'; import { EmptyState } from '@op/ui/EmptyState'; import { Header2 } from '@op/ui/Header'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; import type { Key } from '@op/ui/RAC'; import { Sortable } from '@op/ui/Sortable'; import { cn } from '@op/ui/utils'; @@ -76,6 +77,11 @@ export function RubricEditorContent({ // Accordion expansion state const [expandedKeys, setExpandedKeys] = useState>(new Set()); + // Delete confirmation modal + const [criterionToDelete, setCriterionToDelete] = useState( + null, + ); + const setRubricTemplateSchema = useProcessBuilderStore( (s) => s.setRubricTemplateSchema, ); @@ -139,13 +145,21 @@ export function RubricEditorContent({ }, [t]); const handleRemoveCriterion = useCallback((criterionId: string) => { - setTemplate((prev) => removeCriterion(prev, criterionId)); + setCriterionToDelete(criterionId); + }, []); + + const confirmRemoveCriterion = useCallback(() => { + if (!criterionToDelete) { + return; + } + setTemplate((prev) => removeCriterion(prev, criterionToDelete)); setCriterionErrors((prev) => { const next = new Map(prev); - next.delete(criterionId); + next.delete(criterionToDelete); return next; }); - }, []); + setCriterionToDelete(null); + }, [criterionToDelete]); const handleReorderCriteria = useCallback((newItems: CriterionView[]) => { setTemplate((prev) => @@ -309,6 +323,41 @@ export function RubricEditorContent({
+ + { + if (!open) { + setCriterionToDelete(null); + } + }} + > + {t('Delete criterion')} + +

+ {t( + 'Are you sure you want to delete this criterion? This action cannot be undone.', + )} +

+
+ + + + +
); } diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 2e0ee25f0..7e82473e5 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -922,5 +922,7 @@ "Text response only": "শুধুমাত্র পাঠ্য প্রতিক্রিয়া", "No score, just written feedback": "কোনো স্কোর নেই, শুধু লিখিত প্রতিক্রিয়া", "Score labels cannot be empty": "স্কোর লেবেল খালি রাখা যাবে না", - "Start typing...": "টাইপ শুরু করুন..." + "Start typing...": "টাইপ শুরু করুন...", + "Delete criterion": "মানদণ্ড মুছুন", + "Are you sure you want to delete this criterion? This action cannot be undone.": "আপনি কি নিশ্চিত যে এই মানদণ্ডটি মুছতে চান? এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index f4fe3f98c..022fc7b46 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -915,5 +915,7 @@ "Text response only": "Text response only", "No score, just written feedback": "No score, just written feedback", "Score labels cannot be empty": "Score labels cannot be empty", - "Start typing...": "Start typing..." + "Start typing...": "Start typing...", + "Delete criterion": "Delete criterion", + "Are you sure you want to delete this criterion? This action cannot be undone.": "Are you sure you want to delete this criterion? This action cannot be undone." } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index d658a39bc..61145d9d3 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -915,5 +915,7 @@ "Text response only": "Solo respuesta de texto", "No score, just written feedback": "Sin puntuación, solo retroalimentación escrita", "Score labels cannot be empty": "Las etiquetas de puntuación no pueden estar vacías", - "Start typing...": "Comience a escribir..." + "Start typing...": "Comience a escribir...", + "Delete criterion": "Eliminar criterio", + "Are you sure you want to delete this criterion? This action cannot be undone.": "¿Está seguro de que desea eliminar este criterio? Esta acción no se puede deshacer." } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 6baf3f318..42805064d 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -915,5 +915,7 @@ "Text response only": "Réponse textuelle uniquement", "No score, just written feedback": "Pas de note, uniquement des commentaires écrits", "Score labels cannot be empty": "Les libellés des notes ne peuvent pas être vides", - "Start typing...": "Commencez à écrire..." + "Start typing...": "Commencez à écrire...", + "Delete criterion": "Supprimer le critère", + "Are you sure you want to delete this criterion? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce critère ? Cette action est irréversible." } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 62aa2bc8a..010fd0f3f 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -915,5 +915,7 @@ "Text response only": "Apenas resposta de texto", "No score, just written feedback": "Sem pontuação, apenas feedback escrito", "Score labels cannot be empty": "Os rótulos das notas não podem estar vazios", - "Start typing...": "Comece a digitar..." + "Start typing...": "Comece a digitar...", + "Delete criterion": "Excluir critério", + "Are you sure you want to delete this criterion? This action cannot be undone.": "Tem certeza de que deseja excluir este critério? Esta ação não pode ser desfeita." } From 4bb3ae4e5c3770258afd29d739138f5b5429f2dc Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 16:05:08 -0500 Subject: [PATCH 10/38] Make entire criterion header row trigger accordion expand/collapse Move the 'Criterion N' label inside AccordionTrigger so clicking anywhere on the header (except drag handle and delete button) toggles the accordion. --- .../stepContent/rubric/RubricCriterionCard.tsx | 8 ++++---- .../stepContent/rubric/RubricEditorContent.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx index cd99d6c6c..074f44221 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx @@ -84,12 +84,12 @@ export function RubricCriterionCard({ aria-label={t('Drag to reorder criterion')} /> )} - + + + {t('Criterion {number}', { number: index })} + - - {t('Criterion {number}', { number: index })} - {onRemove && (
diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index 603b849b9..f017c88b8 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -203,9 +203,9 @@ export function RubricEditorContent({ ); const handleUpdateScoreLabel = useCallback( - (criterionId: string, scoreIndex: number, label: string) => { + (criterionId: string, scoreValue: number, label: string) => { setTemplate((prev) => - updateScoreLabel(prev, criterionId, scoreIndex, label), + updateScoreLabel(prev, criterionId, scoreValue, label), ); }, [], diff --git a/apps/app/src/components/decisions/rubricTemplate.ts b/apps/app/src/components/decisions/rubricTemplate.ts index 817b44d66..6070b56ec 100644 --- a/apps/app/src/components/decisions/rubricTemplate.ts +++ b/apps/app/src/components/decisions/rubricTemplate.ts @@ -217,7 +217,7 @@ export function getCriterionScoreLabels( 'title' in e && typeof (e as { title: unknown }).title === 'string', ) - .sort((a, b) => a.const - b.const) + .sort((a, b) => b.const - a.const) .map((e) => e.title); } @@ -482,11 +482,13 @@ export function updateScoredMaxPoints( /** * Update a single score label for a scored criterion. + * `scoreValue` is the 1-based score (the `.const` in the oneOf entry), + * not an array index. */ export function updateScoreLabel( template: RubricTemplateSchema, criterionId: string, - scoreIndex: number, + scoreValue: number, label: string, ): RubricTemplateSchema { const schema = getCriterionSchema(template, criterionId); @@ -494,8 +496,13 @@ export function updateScoreLabel( return template; } - const oneOf = schema.oneOf.map((entry, i) => { - if (i === scoreIndex && typeof entry === 'object' && entry !== null) { + const oneOf = schema.oneOf.map((entry) => { + if ( + typeof entry === 'object' && + entry !== null && + 'const' in entry && + (entry as { const: number }).const === scoreValue + ) { return { ...entry, title: label }; } return entry; From 45aa385cc1a81443488c9343e0887c7810253218 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Mon, 2 Mar 2026 16:55:08 -0500 Subject: [PATCH 17/38] Preserve scored config when switching criterion type and back Cache the scored criterion's max points and score labels in a ref when the user switches away from 'scored'. If they switch back, the cached config is restored instead of generating blank defaults. The cache is cleared when a criterion is deleted. --- .../rubric/RubricEditorContent.tsx | 22 ++++++++- .../components/decisions/rubricTemplate.ts | 49 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index f017c88b8..63ccdfc05 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -19,6 +19,7 @@ import { useTranslations } from '@/lib/i18n'; import type { CriterionView, RubricCriterionType, + ScoredConfig, } from '../../../../decisions/rubricTemplate'; import { addCriterion, @@ -26,6 +27,7 @@ import { createEmptyRubricTemplate, getCriteria, getCriterionErrors, + getScoredConfig, removeCriterion, reorderCriteria, updateCriterionDescription, @@ -82,6 +84,9 @@ export function RubricEditorContent({ null, ); + // Cache scored config so switching type and back doesn't lose score labels + const scoredConfigCacheRef = useRef>(new Map()); + const setRubricTemplateSchema = useProcessBuilderStore( (s) => s.setRubricTemplateSchema, ); @@ -158,6 +163,7 @@ export function RubricEditorContent({ next.delete(criterionToDelete); return next; }); + scoredConfigCacheRef.current.delete(criterionToDelete); setCriterionToDelete(null); }, [criterionToDelete]); @@ -188,7 +194,21 @@ export function RubricEditorContent({ const handleChangeType = useCallback( (criterionId: string, newType: RubricCriterionType) => { - setTemplate((prev) => changeCriterionType(prev, criterionId, newType)); + setTemplate((prev) => { + // Stash scored config before switching away from scored + const currentConfig = getScoredConfig(prev, criterionId); + if (currentConfig) { + scoredConfigCacheRef.current.set(criterionId, currentConfig); + } + + // Restore cached scored config when switching back to scored + const cached = + newType === 'scored' + ? scoredConfigCacheRef.current.get(criterionId) + : undefined; + + return changeCriterionType(prev, criterionId, newType, cached); + }); }, [], ); diff --git a/apps/app/src/components/decisions/rubricTemplate.ts b/apps/app/src/components/decisions/rubricTemplate.ts index 6070b56ec..135796b78 100644 --- a/apps/app/src/components/decisions/rubricTemplate.ts +++ b/apps/app/src/components/decisions/rubricTemplate.ts @@ -390,14 +390,57 @@ export function setCriterionRequired( }; } +/** + * Scored configuration that can be cached in component state so the user + * doesn't lose their score labels when temporarily switching criterion type. + */ +export interface ScoredConfig { + maximum: number; + oneOf: { const: number; title: string }[]; +} + +/** + * Extract the scored configuration from a criterion's schema. + * Returns `undefined` if the criterion is not a scored type. + */ +export function getScoredConfig( + template: RubricTemplateSchema, + criterionId: string, +): ScoredConfig | undefined { + const schema = getCriterionSchema(template, criterionId); + if ( + !schema || + schema.type !== 'integer' || + typeof schema.maximum !== 'number' + ) { + return undefined; + } + const oneOf = (Array.isArray(schema.oneOf) ? schema.oneOf : []) + .filter( + (e): e is { const: number; title: string } => + typeof e === 'object' && + e !== null && + 'const' in e && + 'title' in e && + typeof (e as { title: unknown }).title === 'string', + ) + .sort((a, b) => a.const - b.const); + + return { maximum: schema.maximum, oneOf }; +} + /** * Change a criterion's type while preserving its label, description, and * required status. The schema is rebuilt from scratch for the new type. + * + * If switching back to `scored` and a `cachedScoredConfig` is provided, + * the cached maximum / oneOf are restored instead of generating blank defaults. */ export function changeCriterionType( template: RubricTemplateSchema, criterionId: string, newType: RubricCriterionType, + cachedScoredConfig?: ScoredConfig, ): RubricTemplateSchema { const existing = getCriterionSchema(template, criterionId); if (!existing) { @@ -412,6 +455,12 @@ export function changeCriterionType( newSchema.description = existing.description; } + // Restore cached scored config when switching back to scored + if (newType === 'scored' && cachedScoredConfig) { + newSchema.maximum = cachedScoredConfig.maximum; + newSchema.oneOf = cachedScoredConfig.oneOf; + } + return { ...template, properties: { From 99826ee00f9b6693c28dfa770dae8df466d21efc Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Tue, 3 Mar 2026 09:51:52 -0500 Subject: [PATCH 18/38] Move scored config caching from rubricTemplate into RubricEditorContent The ScoredConfig type and getScoredConfig helper were domain utilities but only served the UI caching concern. Move this logic into the component that owns it, keeping rubricTemplate.ts focused on pure schema operations. --- .../rubric/RubricEditorContent.tsx | 39 ++++++++++----- .../components/decisions/rubricTemplate.ts | 49 ------------------- 2 files changed, 28 insertions(+), 60 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index 63ccdfc05..69a0d338d 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -19,7 +19,6 @@ import { useTranslations } from '@/lib/i18n'; import type { CriterionView, RubricCriterionType, - ScoredConfig, } from '../../../../decisions/rubricTemplate'; import { addCriterion, @@ -27,10 +26,11 @@ import { createEmptyRubricTemplate, getCriteria, getCriterionErrors, - getScoredConfig, + getCriterionSchema, removeCriterion, reorderCriteria, updateCriterionDescription, + updateCriterionJsonSchema, updateCriterionLabel, updateScoreLabel, updateScoredMaxPoints, @@ -85,7 +85,9 @@ export function RubricEditorContent({ ); // Cache scored config so switching type and back doesn't lose score labels - const scoredConfigCacheRef = useRef>(new Map()); + const scoredConfigCacheRef = useRef< + Map + >(new Map()); const setRubricTemplateSchema = useProcessBuilderStore( (s) => s.setRubricTemplateSchema, @@ -196,18 +198,33 @@ export function RubricEditorContent({ (criterionId: string, newType: RubricCriterionType) => { setTemplate((prev) => { // Stash scored config before switching away from scored - const currentConfig = getScoredConfig(prev, criterionId); - if (currentConfig) { - scoredConfigCacheRef.current.set(criterionId, currentConfig); + const schema = getCriterionSchema(prev, criterionId); + if ( + schema?.type === 'integer' && + typeof schema.maximum === 'number' && + Array.isArray(schema.oneOf) + ) { + scoredConfigCacheRef.current.set(criterionId, { + maximum: schema.maximum, + oneOf: schema.oneOf, + }); } + // Change the type (rebuilds schema from scratch) + let updated = changeCriterionType(prev, criterionId, newType); + // Restore cached scored config when switching back to scored - const cached = - newType === 'scored' - ? scoredConfigCacheRef.current.get(criterionId) - : undefined; + if (newType === 'scored') { + const cached = scoredConfigCacheRef.current.get(criterionId); + if (cached) { + updated = updateCriterionJsonSchema(updated, criterionId, { + maximum: cached.maximum, + oneOf: cached.oneOf as { const: number; title: string }[], + }); + } + } - return changeCriterionType(prev, criterionId, newType, cached); + return updated; }); }, [], diff --git a/apps/app/src/components/decisions/rubricTemplate.ts b/apps/app/src/components/decisions/rubricTemplate.ts index 135796b78..6070b56ec 100644 --- a/apps/app/src/components/decisions/rubricTemplate.ts +++ b/apps/app/src/components/decisions/rubricTemplate.ts @@ -390,57 +390,14 @@ export function setCriterionRequired( }; } -/** - * Scored configuration that can be cached in component state so the user - * doesn't lose their score labels when temporarily switching criterion type. - */ -export interface ScoredConfig { - maximum: number; - oneOf: { const: number; title: string }[]; -} - -/** - * Extract the scored configuration from a criterion's schema. - * Returns `undefined` if the criterion is not a scored type. - */ -export function getScoredConfig( - template: RubricTemplateSchema, - criterionId: string, -): ScoredConfig | undefined { - const schema = getCriterionSchema(template, criterionId); - if ( - !schema || - schema.type !== 'integer' || - typeof schema.maximum !== 'number' - ) { - return undefined; - } - const oneOf = (Array.isArray(schema.oneOf) ? schema.oneOf : []) - .filter( - (e): e is { const: number; title: string } => - typeof e === 'object' && - e !== null && - 'const' in e && - 'title' in e && - typeof (e as { title: unknown }).title === 'string', - ) - .sort((a, b) => a.const - b.const); - - return { maximum: schema.maximum, oneOf }; -} - /** * Change a criterion's type while preserving its label, description, and * required status. The schema is rebuilt from scratch for the new type. - * - * If switching back to `scored` and a `cachedScoredConfig` is provided, - * the cached maximum / oneOf are restored instead of generating blank defaults. */ export function changeCriterionType( template: RubricTemplateSchema, criterionId: string, newType: RubricCriterionType, - cachedScoredConfig?: ScoredConfig, ): RubricTemplateSchema { const existing = getCriterionSchema(template, criterionId); if (!existing) { @@ -455,12 +412,6 @@ export function changeCriterionType( newSchema.description = existing.description; } - // Restore cached scored config when switching back to scored - if (newType === 'scored' && cachedScoredConfig) { - newSchema.maximum = cachedScoredConfig.maximum; - newSchema.oneOf = cachedScoredConfig.oneOf; - } - return { ...template, properties: { From d8776a3654373eafb0ab528e93b7a9ebf4c5da4d Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Tue, 3 Mar 2026 10:05:25 -0500 Subject: [PATCH 19/38] Hide coming soon on mobile --- .../decisions/ProcessBuilder/ProcessBuilderHeader.tsx | 5 ++++- packages/ui/src/components/RadioGroup.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx index 7ddc35177..223213e9b 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx @@ -256,6 +256,7 @@ const ProcessBuilderHeaderContent = ({ const MobileSidebar = ({ instanceId }: { instanceId: string }) => { const t = useTranslations(); + const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); const navigationConfig = useNavigationConfig(instanceId); const { visibleSteps, currentStep, setStep } = useProcessNavigation(navigationConfig); @@ -297,7 +298,9 @@ const MobileSidebar = ({ instanceId }: { instanceId: string }) => { className="flex h-8 items-center gap-2 bg-transparent selected:bg-neutral-offWhite" > {t(step.labelKey)} - {step.id === 'rubric' && } + {step.id === 'rubric' && !rubricBuilderEnabled && ( + + )} ))} diff --git a/packages/ui/src/components/RadioGroup.tsx b/packages/ui/src/components/RadioGroup.tsx index bad6f6418..1d1f006df 100644 --- a/packages/ui/src/components/RadioGroup.tsx +++ b/packages/ui/src/components/RadioGroup.tsx @@ -33,7 +33,7 @@ export const RadioGroup = (props: RadioGroupProps) => { 'group flex flex-col gap-2', )} > -
); } -// --------------------------------------------------------------------------- -// Drag preview -// --------------------------------------------------------------------------- - export interface FieldConfigCardDragPreviewProps { /** Icon component to display next to the label */ icon: React.ComponentType<{ className?: string }>; From d027689caa49b01684ac5b451d971897676ab5f5 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Tue, 3 Mar 2026 14:20:27 -0500 Subject: [PATCH 27/38] format --- .../ProcessBuilder/stepContent/general/PhasesSectionContent.tsx | 2 +- .../ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx index daea9dfae..d222c3274 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx @@ -15,7 +15,6 @@ import { import { AutoSizeInput } from '@op/ui/AutoSizeInput'; import { Button } from '@op/ui/Button'; import { DatePicker } from '@op/ui/DatePicker'; -import { ConfirmDeleteModal } from '../../components/ConfirmDeleteModal'; import type { Key } from '@op/ui/RAC'; import { DisclosureStateContext } from '@op/ui/RAC'; import { DragHandle, Sortable } from '@op/ui/Sortable'; @@ -35,6 +34,7 @@ import { useTranslations } from '@/lib/i18n'; import { RichTextEditorWithToolbar } from '@/components/RichTextEditor/RichTextEditorWithToolbar'; +import { ConfirmDeleteModal } from '../../components/ConfirmDeleteModal'; import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; import { ToggleRow } from '../../components/ToggleRow'; import type { SectionProps } from '../../contentRegistry'; diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index b6c1170ed..880315bb5 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -7,7 +7,6 @@ import { Accordion, AccordionItem } from '@op/ui/Accordion'; import { Button } from '@op/ui/Button'; import { EmptyState } from '@op/ui/EmptyState'; import { Header2 } from '@op/ui/Header'; -import { ConfirmDeleteModal } from '../../components/ConfirmDeleteModal'; import type { Key } from '@op/ui/RAC'; import { Sortable } from '@op/ui/Sortable'; import { cn } from '@op/ui/utils'; @@ -36,6 +35,7 @@ import { updateScoreLabel, updateScoredMaxPoints, } from '../../../../decisions/rubricTemplate'; +import { ConfirmDeleteModal } from '../../components/ConfirmDeleteModal'; import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; import type { SectionProps } from '../../contentRegistry'; import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; From 46c69b4be3ac02fb12634d26e896312d8585fa54 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Tue, 3 Mar 2026 14:26:31 -0500 Subject: [PATCH 28/38] simplify --- .../stepContent/rubric/RubricEditorContent.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx index 880315bb5..1309c0a37 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -27,6 +27,7 @@ import { getCriteria, getCriterionErrors, getCriterionSchema, + getCriterionType, removeCriterion, reorderCriteria, updateCriterionDescription, @@ -203,15 +204,11 @@ export function RubricEditorContent({ (criterionId: string, newType: RubricCriterionType) => { setTemplate((prev) => { // Stash scored config before switching away from scored - const schema = getCriterionSchema(prev, criterionId); - if ( - schema?.type === 'integer' && - typeof schema.maximum === 'number' && - Array.isArray(schema.oneOf) - ) { + if (getCriterionType(prev, criterionId) === 'scored') { + const schema = getCriterionSchema(prev, criterionId); scoredConfigCacheRef.current.set(criterionId, { - maximum: schema.maximum, - oneOf: schema.oneOf, + maximum: (schema?.maximum as number) ?? 5, + oneOf: (schema?.oneOf as unknown[]) ?? [], }); } From 760649da8093ba576891cbd334f17bee867a02ec Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Tue, 3 Mar 2026 14:37:31 -0500 Subject: [PATCH 29/38] Improve rubricTemplate, less casting, more functional --- .../components/decisions/rubricTemplate.ts | 217 +++++++----------- 1 file changed, 88 insertions(+), 129 deletions(-) diff --git a/apps/app/src/components/decisions/rubricTemplate.ts b/apps/app/src/components/decisions/rubricTemplate.ts index a1b4e2097..cb0e2a790 100644 --- a/apps/app/src/components/decisions/rubricTemplate.ts +++ b/apps/app/src/components/decisions/rubricTemplate.ts @@ -12,6 +12,7 @@ import type { RubricTemplateSchema, XFormatPropertySchema, } from '@op/common/client'; +import type { JSONSchema7 } from 'json-schema'; import type { TranslationKey } from '@/lib/i18n/routing'; @@ -41,11 +42,56 @@ export interface CriterionView { } // --------------------------------------------------------------------------- -// Criterion type ↔ JSON Schema mapping +// Internal helpers // --------------------------------------------------------------------------- const DEFAULT_MAX_POINTS = 5; +/** + * Type guard that narrows a `JSONSchema7Definition` (which is + * `JSONSchema7 | boolean`) to `JSONSchema7`. + */ +function isSchemaObject(entry: JSONSchema7 | boolean): entry is JSONSchema7 { + return typeof entry !== 'boolean'; +} + +/** + * Extract oneOf entries as typed `JSONSchema7[]`, filtering out boolean + * definitions. + */ +function getOneOfEntries(schema: XFormatPropertySchema): JSONSchema7[] { + if (!Array.isArray(schema.oneOf)) { + return []; + } + return schema.oneOf.filter(isSchemaObject); +} + +/** + * Update a single criterion's schema within a template. Returns the template + * unchanged if the criterion doesn't exist. + */ +function updateProperty( + template: RubricTemplateSchema, + criterionId: string, + updater: (schema: XFormatPropertySchema) => XFormatPropertySchema, +): RubricTemplateSchema { + const schema = getCriterionSchema(template, criterionId); + if (!schema) { + return template; + } + return { + ...template, + properties: { + ...template.properties, + [criterionId]: updater(schema), + }, + }; +} + +// --------------------------------------------------------------------------- +// Criterion type ↔ JSON Schema mapping +// --------------------------------------------------------------------------- + /** * Create the JSON Schema for a given criterion type. */ @@ -101,19 +147,12 @@ export function inferCriterionType( } if (xFormat === 'dropdown') { - // Scored: integer type with maximum - if (schema.type === 'integer' && typeof schema.maximum === 'number') { + if (schema.type === 'integer' && schema.maximum != null) { return 'scored'; } - // Yes/No: string with exactly two oneOf entries (yes, no) - if (schema.type === 'string' && Array.isArray(schema.oneOf)) { - const values = schema.oneOf - .filter( - (e): e is { const: string } => - typeof e === 'object' && e !== null && 'const' in e, - ) - .map((e) => e.const); + if (schema.type === 'string') { + const values = getOneOfEntries(schema).map((e) => e.const); if ( values.length === 2 && values.includes('yes') && @@ -131,26 +170,15 @@ export function inferCriterionType( // Readers // --------------------------------------------------------------------------- -function asSchema(def: unknown): XFormatPropertySchema | undefined { - if (typeof def === 'object' && def !== null) { - return def as XFormatPropertySchema; - } - return undefined; -} - export function getCriterionOrder(template: RubricTemplateSchema): string[] { - return (template['x-field-order'] as string[] | undefined) ?? []; + return template['x-field-order'] ?? []; } export function getCriterionSchema( template: RubricTemplateSchema, criterionId: string, ): XFormatPropertySchema | undefined { - const props = template.properties; - if (!props) { - return undefined; - } - return asSchema(props[criterionId]); + return template.properties?.[criterionId]; } export function getCriterionType( @@ -168,27 +196,21 @@ export function getCriterionLabel( template: RubricTemplateSchema, criterionId: string, ): string { - const schema = getCriterionSchema(template, criterionId); - return (schema?.title as string | undefined) ?? ''; + return getCriterionSchema(template, criterionId)?.title ?? ''; } export function getCriterionDescription( template: RubricTemplateSchema, criterionId: string, ): string | undefined { - const schema = getCriterionSchema(template, criterionId); - return schema?.description; + return getCriterionSchema(template, criterionId)?.description; } export function isCriterionRequired( template: RubricTemplateSchema, criterionId: string, ): boolean { - const required = template.required; - if (!Array.isArray(required)) { - return false; - } - return required.includes(criterionId); + return template.required?.includes(criterionId) ?? false; } export function getCriterionMaxPoints( @@ -207,17 +229,13 @@ export function getCriterionScoreLabels( criterionId: string, ): string[] { const schema = getCriterionSchema(template, criterionId); - if (!schema || schema.type !== 'integer' || !Array.isArray(schema.oneOf)) { + if (!schema || schema.type !== 'integer') { return []; } - return schema.oneOf + return getOneOfEntries(schema) .filter( - (e): e is { const: number; title: string } => - typeof e === 'object' && - e !== null && - 'const' in e && - 'title' in e && - typeof (e as { title: unknown }).title === 'string', + (e): e is JSONSchema7 & { const: number; title: string } => + typeof e.const === 'number' && typeof e.title === 'string', ) .sort((a, b) => a.const - b.const) .map((e) => e.title); @@ -337,18 +355,7 @@ export function updateCriterionLabel( criterionId: string, label: string, ): RubricTemplateSchema { - const schema = getCriterionSchema(template, criterionId); - if (!schema) { - return template; - } - - return { - ...template, - properties: { - ...template.properties, - [criterionId]: { ...schema, title: label }, - }, - }; + return updateProperty(template, criterionId, (s) => ({ ...s, title: label })); } export function updateCriterionDescription( @@ -356,25 +363,15 @@ export function updateCriterionDescription( criterionId: string, description: string | undefined, ): RubricTemplateSchema { - const schema = getCriterionSchema(template, criterionId); - if (!schema) { - return template; - } - - const updated = { ...schema }; - if (description) { - updated.description = description; - } else { - delete updated.description; - } - - return { - ...template, - properties: { - ...template.properties, - [criterionId]: updated, - }, - }; + return updateProperty(template, criterionId, (s) => { + const updated = { ...s }; + if (description) { + updated.description = description; + } else { + delete updated.description; + } + return updated; + }); } export function setCriterionRequired( @@ -401,49 +398,28 @@ export function changeCriterionType( criterionId: string, newType: RubricCriterionType, ): RubricTemplateSchema { - const existing = getCriterionSchema(template, criterionId); - if (!existing) { - return template; - } - - const newSchema: XFormatPropertySchema = { - ...createCriterionJsonSchema(newType), - title: existing.title, - }; - if (existing.description) { - newSchema.description = existing.description; - } - - return { - ...template, - properties: { - ...template.properties, - [criterionId]: newSchema, - }, - }; + return updateProperty(template, criterionId, (existing) => { + const newSchema: XFormatPropertySchema = { + ...createCriterionJsonSchema(newType), + title: existing.title, + }; + if (existing.description) { + newSchema.description = existing.description; + } + return newSchema; + }); } /** * Low-level updater for the raw JSON Schema of a criterion. - * Used for updating score labels, max points, dropdown options, etc. + * Used for restoring cached scored config, etc. */ export function updateCriterionJsonSchema( template: RubricTemplateSchema, criterionId: string, updates: Partial, ): RubricTemplateSchema { - const existing = getCriterionSchema(template, criterionId); - if (!existing) { - return template; - } - - return { - ...template, - properties: { - ...template.properties, - [criterionId]: { ...existing, ...updates }, - }, - }; + return updateProperty(template, criterionId, (s) => ({ ...s, ...updates })); } /** @@ -469,17 +445,11 @@ export function updateScoredMaxPoints( title: existingLabels[i] ?? '', })); - return { - ...template, - properties: { - ...template.properties, - [criterionId]: { - ...schema, - maximum: clampedMax, - oneOf, - }, - }, - }; + return updateProperty(template, criterionId, (s) => ({ + ...s, + maximum: clampedMax, + oneOf, + })); } /** @@ -499,24 +469,13 @@ export function updateScoreLabel( } const oneOf = schema.oneOf.map((entry) => { - if ( - typeof entry === 'object' && - entry !== null && - 'const' in entry && - (entry as { const: number }).const === scoreValue - ) { + if (isSchemaObject(entry) && entry.const === scoreValue) { return { ...entry, title: label }; } return entry; }); - return { - ...template, - properties: { - ...template.properties, - [criterionId]: { ...schema, oneOf }, - }, - }; + return updateProperty(template, criterionId, (s) => ({ ...s, oneOf })); } // --------------------------------------------------------------------------- From 183600b8a5c8d307361364fbdd709b73263f016d Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Tue, 3 Mar 2026 15:49:49 -0500 Subject: [PATCH 30/38] Show rubric preview at smaller breakpoints --- .../stepContent/rubric/RubricParticipantPreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx index 647d1725f..04f04262b 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx @@ -30,7 +30,7 @@ export function RubricParticipantPreview({ } return ( -