From 4e49759b1ba3f2fde1a44628d348371321ceb146 Mon Sep 17 00:00:00 2001 From: chyzman Date: Wed, 22 Jul 2026 18:36:52 -0400 Subject: [PATCH 01/21] better default state handling --- .../checklist/ModerationChecklist.vue | 11 +- .../ui/moderation/checklist/NodeRenderer.vue | 23 ++- .../stages/{other-rules.tsx => rules.tsx} | 1 - .../moderation/src/data/stages/title-slug.tsx | 8 +- packages/moderation/src/types/node.ts | 179 +++++++++++++++++- 5 files changed, 198 insertions(+), 24 deletions(-) rename packages/moderation/src/data/stages/{other-rules.tsx => rules.tsx} (98%) diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index 21acd31a13..0845fc9de5 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -238,7 +238,15 @@ (nodeStates[currentStageObj.id!] ?? {}) as Record, ) " - :show-context="(nodeStates[currentStageObj.id!] ?? {}) as Record" + :show-context=" + withDefaults( + (nodeStates[currentStageObj.id!] ?? {}) as Record, + resolveChildren( + currentStageObj, + (nodeStates[currentStageObj.id!] ?? {}) as Record, + ), + ) + " :on-image-upload="onUploadHandler" :parent-state-path="currentStageObj._statePath ?? []" /> @@ -406,6 +414,7 @@ import { setMissingMdHandler, useStages, walkNodes, + withDefaults, } from '@modrinth/moderation' import { Avatar, diff --git a/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue b/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue index 92641ce1b1..0496470fde 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue @@ -24,6 +24,7 @@ import { resolve, resolveChildren, setMessageProject, + withDefaults, } from '@modrinth/moderation' import { ButtonStyled, @@ -339,18 +340,20 @@ function childScopedContext(child: IdentifiedNodeBuilder): Record + const raw = state as Record + return withDefaults(raw, resolveChildren(child, raw)) } const existing = scopedContextFallbacks.get(child) - if (existing) return existing - const fallback = new Proxy({} as Record, { - set(_target, key, value) { - setAtPath([...basePath, key as string], value as NodeState) - return true - }, - }) - scopedContextFallbacks.set(child, fallback) - return fallback + const fallback = + existing ?? + new Proxy({} as Record, { + set(_target, key, value) { + setAtPath([...basePath, key as string], value as NodeState) + return true + }, + }) + if (!existing) scopedContextFallbacks.set(child, fallback) + return withDefaults(fallback, resolveChildren(child, {})) } function getChildrenContext(node: IdentifiedNodeBuilder): Record { diff --git a/packages/moderation/src/data/stages/other-rules.tsx b/packages/moderation/src/data/stages/rules.tsx similarity index 98% rename from packages/moderation/src/data/stages/other-rules.tsx rename to packages/moderation/src/data/stages/rules.tsx index 484d158c86..a5ef0a88ec 100644 --- a/packages/moderation/src/data/stages/other-rules.tsx +++ b/packages/moderation/src/data/stages/rules.tsx @@ -92,7 +92,6 @@ export default function () { .message(), toggle('rule-breaking-other', 'Other') - // TODO: chyz, the required asterisk is on a separate line .suggestedStatus('rejected') .severity('critical') .message(undefined, (state) => ({ MESSAGE: state.message })) diff --git a/packages/moderation/src/data/stages/title-slug.tsx b/packages/moderation/src/data/stages/title-slug.tsx index 8844416aa9..0c297d6433 100644 --- a/packages/moderation/src/data/stages/title-slug.tsx +++ b/packages/moderation/src/data/stages/title-slug.tsx @@ -47,9 +47,7 @@ export default function () { let slugDebounceTimer: ReturnType | undefined function currentSlug(state: Record) { - return ( - (state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value ?? project.value.slug - ) + return state['correct-slug'] as string } async function checkSlugTaken(slug: string): Promise { @@ -326,10 +324,8 @@ export default function () { }))(state) }) .fix( - //TODO chyz think of some way to have initial values actually be reflected in state without having to store them fix().project((patch, state) => { - const slug = - (state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value + const slug = state['correct-slug'] if (!slug || slug === project.value.slug) return patch.slug = slug }), diff --git a/packages/moderation/src/types/node.ts b/packages/moderation/src/types/node.ts index e44894b639..a57fa84b83 100644 --- a/packages/moderation/src/types/node.ts +++ b/packages/moderation/src/types/node.ts @@ -635,6 +635,11 @@ function stampChildPaths(entries: ChildEntry[], scopePath: string[]): void { } } +/** + * Whether a node counts as "active" given its resolved state. `state` is expected to already + * reflect the node's default (see `withDefaults`) when nothing's been explicitly written — + * this only interprets the value, it doesn't apply any defaulting of its own. + */ export function isNodeActive(node: NodeBuilder, state: NodeState): boolean { switch (node.type) { case 'toggle': @@ -645,7 +650,7 @@ export function isNodeActive(node: NodeBuilder, state: NodeState): boolean { const v = (state as NodeStateWithChildren).value if (typeof v === 'boolean') return v } - return (node as BooleanNodeBuilder)._defaultValue === true + return false } case 'group': { const g = node as GroupNodeBuilder @@ -746,6 +751,11 @@ export function walkNodes( localState: Record, ) => void, ): void { + // Consumer-facing state (fix/message segments) gets defaulting-aware reads; the + // traversal below stays on raw `stageState` for structural decisions (isNodeActive, + // resolveChildren) so defaults never change what gets walked, only what's read. + const scopedState = withDefaults(stageState, nodes) + for (const node of nodes) { if (!(node instanceof NodeBuilder)) continue if (node._shown !== undefined && !resolve(node._shown)) continue @@ -791,8 +801,12 @@ export function walkNodes( node.type === 'group' ? ((node as GroupNodeBuilder)._selectId ?? identified.id!) : identified.id! - const nodeState = stageState[stateKey] - visitor(identified, nodeState, stageState) + // Defaulting-aware: isNodeActive and the visitor callback (fix/message evaluation) should + // see a field's default when nothing's been written yet. Structural recursion below stays + // on the raw value so descending into children never depends on what a default resolves to. + const rawNodeState = stageState[stateKey] + const nodeState = scopedState[stateKey] + visitor(identified, nodeState, scopedState) const active = isNodeActive(node, nodeState) const children = resolveChildren(identified, stageState) @@ -816,11 +830,11 @@ export function walkNodes( (rawChildState as NodeStateWithChildren).value === undefined ? { ...(rawChildState as NodeStateWithChildren), value: true } : (rawChildState ?? true) - visitor(childId, childState, stageState) + visitor(childId, childState, scopedState) walkNodes(resolveChildren(childId, stageState), stageState, visitor) } } else if (node.type === 'toggle' || node.type === 'check') { - const childState = getBooleanChildState(nodeState) + const childState = getBooleanChildState(rawNodeState) walkNodes(children, childState, visitor) } else if ( (node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'single') || @@ -841,7 +855,7 @@ export function walkNodes( (rawChildState as NodeStateWithChildren).value === undefined ? { ...(rawChildState as NodeStateWithChildren), value: true } : (rawChildState ?? true) - visitor(childId, childState, stageState) + visitor(childId, childState, scopedState) walkNodes(resolveChildren(childId, stageState), stageState, visitor) break } @@ -852,6 +866,159 @@ export function walkNodes( } } +// ─── Self-defaulting state ──────────────────────────────────────────────────── + +export function resolveDefault( + node: { _defaultValue?: NodeState | ((state: Record) => NodeState) }, + state: Record, +): NodeState { + const d = node._defaultValue + return typeof d === 'function' ? d(state) : d +} + +const defaultingProxyCache = new WeakMap() + +/** + * A stand-in for a container that hasn't been written to yet (e.g. a group or toggle + * nobody has touched), so reads can chain arbitrarily deep without checking existence + * first. Reads resolve defaults the same way `withDefaults` does. A write cascades back + * up through `writeSelf`, materializing every not-yet-real ancestor along the way as a + * single patch on the nearest real object — nothing is written until something actually is. + */ +function emptyScope( + children: ChildNode[], + writeSelf: (patch: Record) => void, +): Record { + const childMap = new Map() + for (const child of children) { + if (child instanceof IdentifiedNodeBuilder && child.id) childMap.set(child.id, child) + } + + const resolving = new Set() + const emptyCache = new Map>() + + const proxy: Record = new Proxy( + {}, + { + get(_target, key) { + if (typeof key !== 'string') return undefined + const child = childMap.get(key) + if (!child) return undefined + + if (!resolving.has(key)) { + resolving.add(key) + try { + const def = resolveDefault(child, proxy) + if (def !== undefined) return def + } finally { + resolving.delete(key) + } + } + + const cached = emptyCache.get(key) + if (cached) return cached + + const grandchildren = resolveChildren(child, {}) + if (grandchildren.length === 0) return undefined + + const nested = emptyScope(grandchildren, (patch) => writeSelf({ [key]: patch })) + emptyCache.set(key, nested) + return nested + }, + set(_target, key, value) { + if (typeof key === 'string') writeSelf({ [key]: value as NodeState }) + return true + }, + deleteProperty() { + return true + }, + }, + ) as Record + + return proxy +} + +/** + * Wraps a state object so reading an unset key transparently returns that field's + * `.initial()` default instead of `undefined`, without needing to store it, and so + * reading into a container nobody has touched yet (a group/toggle with no state at all) + * returns a safely-chainable empty scope instead of `undefined`. Writes pass through to + * the real underlying object, materializing not-yet-real containers the first time + * something is actually written into them. Nested objects are wrapped lazily on read, + * scoped to whichever child's own children apply at that depth, so the wrap only needs + * to happen once at the top of a scope for defaulting to work at any depth beneath it. + */ +export function withDefaults>( + rawState: T, + children: ChildNode[], +): T { + if (rawState == null || typeof rawState !== 'object' || rawState instanceof Set) return rawState + + const cached = defaultingProxyCache.get(rawState) + if (cached) return cached as T + + const childMap = new Map() + for (const child of children) { + if (child instanceof IdentifiedNodeBuilder && child.id) childMap.set(child.id, child) + } + + // Guards a default function that reads its own key from recursing into itself forever. + const resolving = new Set() + const emptyCache = new Map>() + + const proxy = new Proxy(rawState, { + get(target, key, receiver) { + if (typeof key !== 'string') return Reflect.get(target, key, receiver) + const child = childMap.get(key) + let value = Reflect.get(target, key, receiver) + + if (value === undefined && child !== undefined && !resolving.has(key)) { + resolving.add(key) + try { + value = resolveDefault(child, proxy) + } finally { + resolving.delete(key) + } + } + + if ( + child !== undefined && + value !== null && + typeof value === 'object' && + !(value instanceof Set) + ) { + const nested = value as Record + return withDefaults(nested, resolveChildren(child, nested)) + } + + if (value === undefined && child !== undefined) { + const cachedEmpty = emptyCache.get(key) + if (cachedEmpty) return cachedEmpty + + const grandchildren = resolveChildren(child, {}) + if (grandchildren.length > 0) { + const nested = emptyScope(grandchildren, (patch) => { + Reflect.set(target, key, patch, receiver) + }) + emptyCache.set(key, nested) + return nested + } + } + + return value + }, + set(target, key, value, receiver) { + return Reflect.set(target, key, value, receiver) + }, + deleteProperty(target, key) { + return Reflect.deleteProperty(target, key) + }, + }) as T + + defaultingProxyCache.set(rawState, proxy) + return proxy +} + // ─── Factory functions ──────────────────────────────────────────────────────── export type StageFn = ( From 560ee6c19849aca2379a21b4f09e6060c731c19b Mon Sep 17 00:00:00 2001 From: chyzman Date: Wed, 22 Jul 2026 14:56:24 -0400 Subject: [PATCH 02/21] make next stage button work for going to next project --- .../ui/moderation/checklist/ModerationChecklist.vue | 10 ++++++++++ packages/moderation/src/data/keybinds.ts | 1 - 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index 0845fc9de5..90148ae562 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -1542,6 +1542,16 @@ function previousStage() { } function nextStage() { + if (done.value) { + endChecklist(undefined) + return + } + + if (alreadyReviewed.value || isLockedByOther.value) { + if (moderationQueue.isQueueMode && moderationQueue.queueLength > 1) skipToNextProject() + return + } + if (generatedMessage.value) return let targetStage = currentStage.value + 1 diff --git a/packages/moderation/src/data/keybinds.ts b/packages/moderation/src/data/keybinds.ts index b86029e2ac..67108ecc1b 100644 --- a/packages/moderation/src/data/keybinds.ts +++ b/packages/moderation/src/data/keybinds.ts @@ -4,7 +4,6 @@ const keybinds: { [id: string]: KeybindListener } = { 'next-stage': { keybind: 'ArrowRight', description: 'Go to next stage', - enabled: (ctx) => !ctx.state.isDone, action: (ctx) => ctx.actions.tryGoNext(), }, 'previous-stage': { From a31c3dd3f514b419267d8744a02d14feb67b19c3 Mon Sep 17 00:00:00 2001 From: chyzman Date: Wed, 22 Jul 2026 18:52:36 -0400 Subject: [PATCH 03/21] fix skill issue --- packages/moderation/src/data/checklist.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/moderation/src/data/checklist.ts b/packages/moderation/src/data/checklist.ts index 4e5383715c..099bcdf9f5 100644 --- a/packages/moderation/src/data/checklist.ts +++ b/packages/moderation/src/data/checklist.ts @@ -13,7 +13,7 @@ import usePermissionsStage from './stages/permissions' import usePostApprovalStage from './stages/post-approval' import useReReviewStage from './stages/re-review' import useReuploadsStage from './stages/reupload' -import useOtherRulesStage from './stages/other-rules' +import useRulesStage from './stages/rules' import useStatusAlertsStage from './stages/status-alerts' import useSummaryStage from './stages/summary' import useTitleSlugStage from './stages/title-slug' @@ -38,7 +38,7 @@ export function useStages( useVersionsStage(), useReuploadsStage(), usePermissionsStage(), - useOtherRulesStage(), + useRulesStage(), ] provide(STAGES_KEY, ref(mainStages)) return [...mainStages, useStatusAlertsStage(mainStages, globalState)] From fb75143b9b12120db1f6987157b55fff65aa4ef9 Mon Sep 17 00:00:00 2001 From: chyzman Date: Fri, 24 Jul 2026 20:52:42 -0400 Subject: [PATCH 04/21] actually fix initial values this time, make single line inputs with varying lengths (text input and dropdown) use their widest possible length instead of whatever the hell they used to be --- .../checklist/ModerationChecklist.vue | 10 +++++---- .../ui/moderation/checklist/NodeRenderer.vue | Bin 26570 -> 28176 bytes .../moderation/src/data/stages/title-slug.tsx | 13 ++++++++--- packages/moderation/src/types/node.ts | 21 ++++++++++++++---- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index 90148ae562..5d8629375f 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -1064,10 +1064,12 @@ const checklistLive = computed>(() => { walkNodes(resolveChildren(stage, stageState), stageState, (node, nodeState, localState) => { isVisible = true const active = isNodeActive(node, nodeState) - const actionState = - node.type === 'toggle' || node.type === 'check' || node.type === 'option' - ? (getBooleanChildState(nodeState) as Record) - : localState + const actionState = (() => { + if (node.type !== 'toggle' && node.type !== 'check' && node.type !== 'option') + return localState + const childState = getBooleanChildState(nodeState) as Record + return withDefaults(childState, resolveChildren(node, childState)) + })() const isRequired = !!(node as ValueNodeBuilder)._required const nodeActiveActions: ActiveAction[] = [] diff --git a/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue b/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue index 0496470fde2e143cb4ba8ee09f8163916d399606..d187bc3b3539211e5fe49f6aa2d6c66af6c0edb9 100644 GIT binary patch delta 1834 zcmZ`(&u<$=6ecaS?4}4L6cjG#5kR}!dbdv0BGtk{)Cp;&q^XirrBo;x?~c8b%+9QK zW@DRRnLh!^Tsh^Ako;ftg1F~|;Le}GoBiQbBKTnY&AcDq_vU@?)$a>GT`c_e*7ezY z*B`$>xqBB5GS=_Y3{nzBO!UD`hyFdd4Qn3U0UcB5lN7qNCo@`@VN6dmDZs^Ze=WFQ zyVd7RE6RH|W_A_(p8EH_1|&p>u}R*MNmq8|&`}@<0w%gDo{;P$PvOrWe}=DA5SJKaN5&&=0CtsIxNq$ zO!25$7-Pe9-ioe_Mgd7vDxyxzcytL6-NyE1{{ln0gIvwywxM`6+p4;s&`C83L7(b} z)r@sGL+3qY?|Q+&JhnZy%{U*R7NSh15l(J3zcMxaaa@fyD5t5+G>t&!+Dxqx4<{pI z>5O96lq}Bbp%2MKI2w#fO#V_hpr!c-SefFCmlfcY5$Npi1L0E8LMGLd03~5u^1_lm zg+oFG2J5&eMM9kuZnD=!%>x=GGE@-qkN$%yl2jR3$9a+>G1Cp`I#kVgkWnIRN(06Qf8qLc}iD&(^6dX=<3!&KP%jjF44xGQ9Y6H*^f|M_rf zdbVs?b-UZsqow5uK6SYVPUr}0RhL0RQrDFpwAVof#e;jnv1Gz^-Y{}0LqY>E!l)~e zn{jY4E4mvsm(Fw1mZm+ab0uVWxfn3z4$(297`n{{1VL~GuIlYBqud&)H%FLJm2)$* zN1vV*8AHmr`&zA+IyS5K|Ciu69Uj@%)dW>Zuz`!nl)0e*nS!{qa@O!uuo$qGvK2+D1jLa>oRcxI0N@-bB_Js&*J?D*Y-({8bGGFzD&tFyVWX|PkZGIaJLn6*1B?fh^i;Y u#dVW-9*k$_&lcaTE6sj=@%N483VOLF?=_*v>Cj(Yv8Us2*Uw)a-TW8H?qnbU delta 189 zcmbPmhw;>T#tp2@ocVda`K5U!sVSS8nZ1Q4@0SvtEE~hMd8v#Uk04x5Lqk)+)=r^% za=*IC60&JN(sURG&Q+6Yq@GQH{aBlFE_c;U3>F-_hKeR z1*_zo#NuLGrOcAlT)mRYf>Z@XTU!P7^rHOI0(Av7waJlQx{OMjCwXymO#T&NyZK>| e5ZmTo5oJtF76zLSM{~1mz8e3OZS&1Eb9MkfV?t*D diff --git a/packages/moderation/src/data/stages/title-slug.tsx b/packages/moderation/src/data/stages/title-slug.tsx index 0c297d6433..2ebceb7714 100644 --- a/packages/moderation/src/data/stages/title-slug.tsx +++ b/packages/moderation/src/data/stages/title-slug.tsx @@ -64,8 +64,15 @@ export default function () { } const SlugStatus = () => { - const v = slugValidation.value - if (v === null) return null + // Untouched — reflect the auto-suggested slug already sitting in the field (the value + // that'll actually be used until the moderator overrides it) via the same states below. + const v = + slugValidation.value ?? + (autoSlugStatus.value === 'loading' + ? 'checking' + : autoSlugStatus.value === 'unavailable' + ? 'taken' + : 'available') if (v === 'checking') return ( @@ -325,7 +332,7 @@ export default function () { }) .fix( fix().project((patch, state) => { - const slug = state['correct-slug'] + const slug = state['correct-slug'] as string if (!slug || slug === project.value.slug) return patch.slug = slug }), diff --git a/packages/moderation/src/types/node.ts b/packages/moderation/src/types/node.ts index a57fa84b83..74040942cb 100644 --- a/packages/moderation/src/types/node.ts +++ b/packages/moderation/src/types/node.ts @@ -831,7 +831,8 @@ export function walkNodes( ? { ...(rawChildState as NodeStateWithChildren), value: true } : (rawChildState ?? true) visitor(childId, childState, scopedState) - walkNodes(resolveChildren(childId, stageState), stageState, visitor) + const nestedState = getBooleanChildState(rawChildState) + walkNodes(resolveChildren(childId, nestedState), nestedState, visitor) } } else if (node.type === 'toggle' || node.type === 'check') { const childState = getBooleanChildState(rawNodeState) @@ -856,7 +857,8 @@ export function walkNodes( ? { ...(rawChildState as NodeStateWithChildren), value: true } : (rawChildState ?? true) visitor(childId, childState, scopedState) - walkNodes(resolveChildren(childId, stageState), stageState, visitor) + const nestedState = getBooleanChildState(rawChildState) + walkNodes(resolveChildren(childId, nestedState), nestedState, visitor) break } } @@ -957,10 +959,21 @@ export function withDefaults>( const cached = defaultingProxyCache.get(rawState) if (cached) return cached as T + // Id-less nodes (e.g. an unnamed `group()` used purely for layout/title) don't nest state + // under their own key, so their children read/write directly into this same scope — flatten + // through them so those grandchildren are still reachable by id for defaulting purposes. const childMap = new Map() - for (const child of children) { - if (child instanceof IdentifiedNodeBuilder && child.id) childMap.set(child.id, child) + function collectChildMap(nodes: ChildNode[]) { + for (const child of nodes) { + if (!(child instanceof IdentifiedNodeBuilder)) continue + if (child.id) { + childMap.set(child.id, child) + } else { + collectChildMap(resolveChildren(child, rawState)) + } + } } + collectChildMap(children) // Guards a default function that reads its own key from recursing into itself forever. const resolving = new Set() From 557f6a01c2b84e3877f190bc2db4b784c746bd1e Mon Sep 17 00:00:00 2001 From: chyzman Date: Sat, 25 Jul 2026 01:52:08 -0400 Subject: [PATCH 05/21] loaders in progress --- .../checklist/ModerationChecklist.vue | 7 +- .../ui/moderation/checklist/NodeRenderer.vue | Bin 28176 -> 29115 bytes .../messages/metadata/loader/correction.md | 1 + .../messages/metadata/loader/inaccurate.md | 5 + .../moderation/src/data/stages/metadata.tsx | 116 +++++----- packages/moderation/src/types/node.ts | 218 +++++++++++++++--- 6 files changed, 251 insertions(+), 96 deletions(-) create mode 100644 packages/moderation/src/data/messages/checklist/messages/metadata/loader/correction.md create mode 100644 packages/moderation/src/data/messages/checklist/messages/metadata/loader/inaccurate.md diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index 5d8629375f..fae659d778 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -1065,7 +1065,12 @@ const checklistLive = computed>(() => { isVisible = true const active = isNodeActive(node, nodeState) const actionState = (() => { - if (node.type !== 'toggle' && node.type !== 'check' && node.type !== 'option') + if ( + node.type !== 'toggle' && + node.type !== 'check' && + node.type !== 'switch' && + node.type !== 'option' + ) return localState const childState = getBooleanChildState(nodeState) as Record return withDefaults(childState, resolveChildren(node, childState)) diff --git a/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue b/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue index d187bc3b3539211e5fe49f6aa2d6c66af6c0edb9..0041f981c5f01bdfda8a6efa3e665774a8d07b13 100644 GIT binary patch delta 2279 zcmah~&2Jk;6qnPM#7;S+P4hv&o}J?Cmdz&VfrC}IYRZRzs;Zh$p+;%4-kpu7Ue7GM zvreqka{qx!g()C$peOD~D{BS`G*h2YTR$1VZA?`eU6hVad|Y%zJO%`~80N z-s7J#-~5_+yf%HE*L}_i6F0d-ZUx*AuX85Iw)p7ovs1ecmm=ypBrxQq%->V66bZrE zvS@n5F#ruKyu!B)`Qpg5{C;RWemYbgIX>twKRiE??Qh8EBj?i9$Jwda8h!qfoEw{x z{|=AI&&MyxCnuiCN5x6GaDG^BpZw$qbwTp^3HjpOjVXt?Kv^g(W)tqhG7)eUa5G*Q zzc`$Mc=hzB$MTy*^zXZ4c@fbV)>u->c1o6ioz0I?2aBgJB@UE98OX)L>2^Rw6tHA% zt~ZqPdz7I?xeT)v&OKtWS-cXjSn8~ng(ZlV2GgOWPFE1geMh-gF6Tzf7L+q()K4hE zUuAiGSDK$Ko|X5Gk8s$@Wo?dSgDtiP>ex#i7P14Jy;HX~$@{*?EvE#&qSoBC39VeV zTPFt2QncDo0L)$3NsvMyri;2soz{kd=e}pzq{@{BlG1LaYfWArojRv0D~E#TbyFo)i7zx6?L@R)leC5-{ zQ(SA5AYwJfx0nv)E70K7xuT#Qz+!bH@*O0!bYZ8HY8@kZDv{R4S_$@sQ1Eq`NfnUF z=}bF|;!Tn|R7^UAhib`y23R3%AqsUAv{tezbc1tCa zWTGPWh1UZJxz){eTK7p{3ECjbO=jx~n{1XL7c11RXk2`s-I6aEg@rIsU6|WcH z&9r-`;x=(ZvqqYsrg!3~`|}!Qew1{a!B>1>Ht^s9Dye6_KR0|2g0hHUiWTDWfS8C% z{N3ziCYJ&#^JDo515^p6K8&S=c{!?Ily~(!wwhD%1ATZ%{-M2EOkF#VSBby?)lmkY zyZrgmc-(r;%(Um{;TmiPJn~_SiYi#D8Cj7hz_mONsk7`F2o6-hRuv`MAOT?a5(H?7 zV#MpwtbJ&nGzn$UP0#E#6pa5yH{&vBz7=3oR0?@p)wEk3UDu?eLLM7p z#D;N?GUCWbXD8*i#)+4av^1jp!6=OM6e0gHW;0jg!rbrU1;wph#i?S7rcXM}HAMXE z>TFkZ^3R!xY+|Bvh+kj2U0CTgjX4D>c5O{fD+B6QP;>B28yJ$GC+%xh=cNFR9{tk&1R{7Qn4je&!oIdjFjhQyinTU1q@3|LxAC0*Rj;(TG z|ByNG0=@58T0PLfT<lX2q%<55bW?OG2*Q9a+`9J3YCm*25{EUa#*(xEnb%(4QNK1s+YdTU(dAsI?wwbpNLio`Y8=*@#l}bso*Q_}W z6T3ne4(=^j6!9HD8m*b*u@ z$s!_xWot;BNjiw!Rg5YU>{U^8zfas?qQ9Rlp>;g>+JMtz^*W?xq9=LYX+sWJZGRmq5?N-R;cR2sGx0Pbto;b>Y67 zU*i29O!%lLW2zU$`RNeb{HEHNVIw~n_O2p@YCxxkEgTJI4JD5ZnTHoE13o?kJ9rXa zssk)`<}4*og#xA=WjGjL8HLAY1j>v4UY4C2k)$RRTJho>o#RNoBRaKOV6#}0`J9JL z8mlYLf`%-D3m9nvCdd?%Sli6OUWp%XPfVJeDct6P1cwZf%O0u#f-w|R4aIR}-o%!) zCS6K#1q0QjG@Zb}qp)T4K|Oy4mZcb+EC=9zE_A(YC@RuzLqiprrxGr9a)I{b`Rbq@ zk#F~{REe$HX4yal$-G@Cz%!K#XQ^o_*eZI?fQB?gEBLz-+$wCE!yca#Ek0(;!JaB= zJ2?XCcZauY{tK(a2D3^9D^f?o)8UN`u4-whfGsNB9|#`)2ZF4r)Fd6@qE2K!lS*|; zYuITTMCJ>M;h@fjjuegJk@C&ooodwHhkPzc%@zGCL3h) { + const projectTypes = new Set(project.value.project_types) + const current = new Set(project.value.loaders) + return loaders.value.filter( + (loader) => + current.has(loader.name) || + loader.supported_project_types.every((t) => projectTypes.has(t)), + ) + }) + return ( stage('metadata', 'Metadata') .hint("Are there any issues with this project's metadata?") @@ -103,67 +113,51 @@ export default function () { .none('Unknown'), ), ), - // TODO: chyz, fix pls (make into single set of buttons where current loaders start selected and non current start non selected - // toggle('loader', `Loader${project.value.loaders.length > 1 ? 's' : ''}`).children( - // group() - // .title('Loader Issues?') - // .action( - // action() - // .suggestedStatus('flagged') - // .severity('medium') - // .message(async (state) => { - // //TODO: chyz - // //TODO: coolbot this one is a bit of a doozy - // const header = await md('checklist/messages/metadata/loader/incorrect')(state) - // const selected = state.loaders - // if (selected instanceof Set && selected.size > 0) { - // const list = [...selected] - // .map((id) => `- ${formatLoaderLabel(id)}`) - // .join('\n') - // return `${header}\n${list}` - // } - // return header - // }), - // ) - // .children( - // toggle('incorrect', 'Incorrect').children( - // group() - // .title('Incorrect Loaders') - // .multiSelect('loaders') - // .children( - // ...project.value.loaders.map((id) => option(id, formatLoaderLabel(id))), - // ), - // ), - // TODO: chyz, this should be the same interface as incorrect, as a corrections scheme, with selected loaders default on. - // toggle('missing', 'Missing').children( - // group() - // .title('Missing Loaders') - // .multiSelect('loaders') - // .children( - // ...(() => { - // //TODO: chyz maybe this can be done better - // // (plugin loaders and datapack are marked as valid for mods which makes this suck) - // const existingTypes = new Set( - // loaders.value - // .filter((l) => project.value.loaders.includes(l.name)) - // .flatMap((l) => l.supported_project_types), - // ) - // const referenceTypes = - // existingTypes.size > 0 - // ? existingTypes - // : new Set(project.value.project_types) - // return loaders.value - // .filter( - // (loader) => - // loader.supported_project_types.every((t) => referenceTypes.has(t)) && - // !project.value.loaders.includes(loader.name), - // ) - // .map((loader) => option(loader.name, formatLoaderLabel(loader.name))) - // })(), - // )/ - // ), - // ), - // ), + + toggle('loader', `Loader${project.value.loaders.length > 1 ? 's' : ''}`) + .suggestedStatus('flagged') + .severity('medium') + .rawMessage(async (state) => { + const selected = + state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders) + const current = new Set(project.value.loaders) + const isCorrected = + selected.size !== current.size || [...selected].some((id) => !current.has(id)) + + let correct = '' + if (isCorrected) { + const list = [...selected].map((id) => formatLoaderLabel(id)).join(', ') + correct = await md('checklist/messages/metadata/loader/correction', () => ({ + LOADERS: list || 'none', + }))(state) + } + + return md('checklist/messages/metadata/loader/inaccurate', () => ({ + CORRECT: correct, + }))(state) + }) + .fix( + fix().project((patch, state) => { + const selected = + state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders) + const next = [...selected] + const current = project.value.loaders + if (next.length === current.length && next.every((id) => current.includes(id))) + return + patch.loaders = next + }), + ) + .children( + group() + .title('Loaders') + .multiSelect('loaders') + .initial(() => new Set(project.value.loaders)) + .children( + ...possibleLoaders.value.map((loader) => + option(loader.name, formatLoaderLabel(loader.name)), + ), + ), + ), ), ) ) diff --git a/packages/moderation/src/types/node.ts b/packages/moderation/src/types/node.ts index 74040942cb..5b71d1ab7e 100644 --- a/packages/moderation/src/types/node.ts +++ b/packages/moderation/src/types/node.ts @@ -1,5 +1,6 @@ import type { Labrinth } from '@modrinth/api-client' -import type { FunctionalComponent, InjectionKey, Ref, SVGAttributes } from 'vue' +import { Checkbox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui' +import type { Component, FunctionalComponent, InjectionKey, Ref, SVGAttributes } from 'vue' import { markRaw, toValue } from 'vue' import { @@ -54,6 +55,7 @@ export type ChildEntry = export type NodeType = | 'toggle' | 'check' + | 'switch' | 'button' | 'text' | 'markdown' @@ -433,13 +435,13 @@ export abstract class ValueNodeBuilder extends LabeledNodeBuilder { } } +/** + * A click-triggered, icon-capable status button (e.g. "flag this issue") — not a plain value + * control, so unlike `check`/`toggleSwitch` (`BooleanComponentNodeBuilder`) it isn't built on + * `ComponentNodeBuilder` and keeps its own dedicated `NodeRenderer` rendering. + */ export class BooleanNodeBuilder extends ValueNodeBuilder { - readonly type: 'toggle' | 'check' - - constructor(id: string, nodeLabel: string, type: 'toggle' | 'check') { - super(id, nodeLabel) - this.type = type - } + readonly type = 'toggle' as const } export type OverrideValue = { readonly __override: string } @@ -449,7 +451,69 @@ export type OnChangeFn = ( helpers: { override: (value: string) => OverrideValue }, ) => OverrideValue | void -export class InputNodeBuilder extends IdentifiedNodeBuilder { +export interface ComponentNodePropsContext { + onImageUpload?: (file: File) => Promise +} + +/** + * Base for any node whose value is edited by mounting a real Vue component rather than + * something `NodeRenderer` has its own hardcoded markup for. `NodeRenderer` renders every + * instance of this the same generic way (``), so a new + * component-backed node type never needs a `NodeRenderer` change — just a subclass (or, for a + * one-off, a direct construction) that fills in `_component` and how its value binds. + */ +export abstract class ComponentNodeBuilder extends IdentifiedNodeBuilder { + _component?: Component + /** v-model prop name; the paired event is `update:${_modelProp}`. Defaults to Vue's own convention. */ + _modelProp: string = 'modelValue' + _componentProps?: (ctx: ComponentNodePropsContext) => Record + /** Extra props layered on top of `_componentProps`, e.g. `.props(() => ({ type: 'email' }))` — + * the escape hatch into whatever the mounted component actually supports, without needing a + * dedicated builder method for every prop it happens to have. */ + _extraProps?: (ctx: ComponentNodePropsContext) => Record + _showTooltip?: boolean + /** Needs the ref-based setValue()/button-resync DOM-sync workaround — see `NodeRenderer`'s + * `componentRefs`. Controlled-input-style components typically need this; components that + * sync themselves off their own `modelValue` watcher (like `MarkdownEditor`) don't. */ + _imperativeSync?: boolean + /** What shape this node's value is, so `NodeRenderer` knows how to read/write it generically + * (e.g. `getTextState`/`setTextState` for `'string'`, `getBooleanState`/`setBooleanState` for + * `'boolean'`) without needing a dedicated render branch per node type. */ + _valueKind: 'string' | 'boolean' = 'string' + + props(fn: (ctx: ComponentNodePropsContext) => Record): this { + this._extraProps = fn + return this + } +} + +/** A component-backed node builder, plus a callable setter for any prop name that isn't + * already a real method/property on it. */ +export type WithAutoProps = T & { [key: string]: (value: unknown) => T } + +/** + * Lets a component-backed node builder be called like `.clearable(true)`/`.size('small')` for + * any prop the mounted component actually accepts, without a dedicated method for each one — + * same underlying mechanism as `.props()`, just spelled as the prop's own name. Falls through to + * the real method/property untouched whenever one already exists with that name (e.g. `.icon()` + * from `NodeBuilder`, or `.type` on `InputNodeBuilder`), so this only ever fills genuine gaps. + */ +function withAutoProps(builder: T): WithAutoProps { + return new Proxy(builder, { + get(target, prop, receiver) { + if (typeof prop === 'string' && !(prop in target)) { + return (value: unknown) => { + const prev = target._extraProps + target._extraProps = (ctx) => ({ ...prev?.(ctx), [prop]: value }) + return receiver + } + } + return Reflect.get(target, prop, receiver) + }, + }) as WithAutoProps +} + +export class InputNodeBuilder extends ComponentNodeBuilder { readonly type: 'text' | 'markdown' _placeholder?: Reactive _defaultValue?: NodeState | ((state: Record) => NodeState) @@ -459,6 +523,20 @@ export class InputNodeBuilder extends IdentifiedNodeBuilder { constructor(id: string, type: 'text' | 'markdown') { super(id) this.type = type + if (type === 'text') { + this._component = markRaw(StyledInput) + this._showTooltip = true + this._imperativeSync = true + this._componentProps = () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }) + } else { + this._component = markRaw(MarkdownEditor) + this._componentProps = (ctx) => ({ + maxHeight: 300, + disabled: false, + headingButtons: false, + onImageUpload: ctx.onImageUpload, + }) + } } placeholder(p: Reactive): this { @@ -482,12 +560,49 @@ export class InputNodeBuilder extends IdentifiedNodeBuilder { } } +/** + * A plain boolean field — `check` renders as a `Checkbox`, `switch` (exposed as the + * `toggleSwitch()` factory, since `switch` is a reserved word) as the `Toggle` slider. Distinct + * from `toggle()`/`BooleanNodeBuilder`, which is a click-triggered, icon-capable status button + * with its own color-coding logic — not a plain value control, so it stays on its own path. + */ +export class BooleanComponentNodeBuilder extends ComponentNodeBuilder { + readonly type: 'check' | 'switch' + label: string + _defaultValue?: NodeState | ((state: Record) => NodeState) + _required?: boolean + + constructor(id: string, label: string, type: 'check' | 'switch') { + super(id) + this.type = type + this.label = label + this._valueKind = 'boolean' + if (type === 'check') { + this._component = markRaw(Checkbox) + this._componentProps = () => ({ label }) + } else { + this._component = markRaw(Toggle) + } + } + + initial(v: NodeState | ((state: Record) => NodeState)): this { + this._defaultValue = v + return this + } + + required(v = true): this { + this._required = v + return this + } +} + export class GroupNodeBuilder extends IdentifiedNodeBuilder { readonly type = 'group' as const _layout?: 'flex' | 'column' _required?: boolean _selectMode?: 'single' | 'multi' _selectId?: string + _defaultValue?: NodeState | ((state: Record) => NodeState) layout(l: 'flex' | 'column'): this { this._layout = l @@ -510,6 +625,12 @@ export class GroupNodeBuilder extends IdentifiedNodeBuilder { if (id !== undefined) this._selectId = id return this } + + /** Selected option(s) to start with before the moderator has touched this group. */ + initial(v: NodeState | ((state: Record) => NodeState)): this { + this._defaultValue = v + return this + } } export class DropdownNodeBuilder extends IdentifiedNodeBuilder { @@ -591,6 +712,7 @@ function childrenScopePath(node: IdentifiedNodeBuilder): string[] | null { switch (node.type) { case 'toggle': case 'check': + case 'switch': case 'option': case 'stage': return node._statePath @@ -644,6 +766,7 @@ export function isNodeActive(node: NodeBuilder, state: NodeState): boolean { switch (node.type) { case 'toggle': case 'check': + case 'switch': case 'option': { if (typeof state === 'boolean') return state if (state && typeof state === 'object' && !(state instanceof Set)) { @@ -834,7 +957,7 @@ export function walkNodes( const nestedState = getBooleanChildState(rawChildState) walkNodes(resolveChildren(childId, nestedState), nestedState, visitor) } - } else if (node.type === 'toggle' || node.type === 'check') { + } else if (node.type === 'toggle' || node.type === 'check' || node.type === 'switch') { const childState = getBooleanChildState(rawNodeState) walkNodes(children, childState, visitor) } else if ( @@ -880,6 +1003,47 @@ export function resolveDefault( const defaultingProxyCache = new WeakMap() +/** + * Id-less nodes (e.g. an unnamed `group()` used purely for layout/title) don't nest state + * under their own key, so their children read/write directly into this same scope — flatten + * through them so those grandchildren are still reachable for defaulting purposes. A select-mode + * group without a structural id still owns a real key (its `_selectId`), same as `walkNodes`' + * own stateKey resolution — only flatten when neither is present. + */ +function buildChildMap( + children: ChildNode[], + stateForFlattening: Record, +): Map { + const childMap = new Map() + function collect(nodes: ChildNode[]) { + for (const child of nodes) { + if (!(child instanceof IdentifiedNodeBuilder)) continue + const selectId = child instanceof GroupNodeBuilder ? child._selectId : undefined + const key = child.id ?? selectId + if (key) { + childMap.set(key, child) + } else { + collect(resolveChildren(child, stateForFlattening)) + } + } + } + collect(children) + return childMap +} + +/** + * A dropdown's or select-mode group's "children" are its enumerable options, not nested + * sub-fields of a container — chaining into them as an empty scope (or a `withDefaults`-wrapped + * nested object) would be wrong, and hands back a Proxy where callers expect the option id + * itself. Only plain groups/toggles/checks actually nest state under their own key. + */ +function isOptionsContainer(child: IdentifiedNodeBuilder): boolean { + return ( + child instanceof DropdownNodeBuilder || + (child instanceof GroupNodeBuilder && child._selectMode !== undefined) + ) +} + /** * A stand-in for a container that hasn't been written to yet (e.g. a group or toggle * nobody has touched), so reads can chain arbitrarily deep without checking existence @@ -891,10 +1055,7 @@ function emptyScope( children: ChildNode[], writeSelf: (patch: Record) => void, ): Record { - const childMap = new Map() - for (const child of children) { - if (child instanceof IdentifiedNodeBuilder && child.id) childMap.set(child.id, child) - } + const childMap = buildChildMap(children, {}) const resolving = new Set() const emptyCache = new Map>() @@ -917,6 +1078,8 @@ function emptyScope( } } + if (isOptionsContainer(child)) return undefined + const cached = emptyCache.get(key) if (cached) return cached @@ -959,21 +1122,7 @@ export function withDefaults>( const cached = defaultingProxyCache.get(rawState) if (cached) return cached as T - // Id-less nodes (e.g. an unnamed `group()` used purely for layout/title) don't nest state - // under their own key, so their children read/write directly into this same scope — flatten - // through them so those grandchildren are still reachable by id for defaulting purposes. - const childMap = new Map() - function collectChildMap(nodes: ChildNode[]) { - for (const child of nodes) { - if (!(child instanceof IdentifiedNodeBuilder)) continue - if (child.id) { - childMap.set(child.id, child) - } else { - collectChildMap(resolveChildren(child, rawState)) - } - } - } - collectChildMap(children) + const childMap = buildChildMap(children, rawState) // Guards a default function that reads its own key from recursing into itself forever. const resolving = new Set() @@ -1004,7 +1153,7 @@ export function withDefaults>( return withDefaults(nested, resolveChildren(child, nested)) } - if (value === undefined && child !== undefined) { + if (value === undefined && child !== undefined && !isOptionsContainer(child)) { const cachedEmpty = emptyCache.get(key) if (cachedEmpty) return cachedEmpty @@ -1047,13 +1196,14 @@ export function stageFn(factory: StageFn): StageFn { } } -export const toggle = (id: string, nodeLabel: string) => - new BooleanNodeBuilder(id, nodeLabel, 'toggle') +export const toggle = (id: string, nodeLabel: string) => new BooleanNodeBuilder(id, nodeLabel) export const check = (id: string, nodeLabel: string) => - new BooleanNodeBuilder(id, nodeLabel, 'check') + withAutoProps(new BooleanComponentNodeBuilder(id, nodeLabel, 'check')) +export const toggleSwitch = (id: string, nodeLabel: string) => + withAutoProps(new BooleanComponentNodeBuilder(id, nodeLabel, 'switch')) export const button = (nodeLabel?: string) => new ButtonNodeBuilder(nodeLabel) -export const text = (id: string) => new InputNodeBuilder(id, 'text') -export const markdown = (id: string) => new InputNodeBuilder(id, 'markdown') +export const text = (id: string) => withAutoProps(new InputNodeBuilder(id, 'text')) +export const markdown = (id: string) => withAutoProps(new InputNodeBuilder(id, 'markdown')) export const group = (id?: string) => new GroupNodeBuilder(id) export const dropdown = (id: string) => new DropdownNodeBuilder(id) export const option = (id: string, nodeLabel: string) => new OptionNodeBuilder(id, nodeLabel) From fb6a15802bed2494a5957444bf5b01ae9baff108 Mon Sep 17 00:00:00 2001 From: chyzman Date: Sat, 25 Jul 2026 03:18:22 -0400 Subject: [PATCH 06/21] loaders and versions test (probably dont use them as the messages suck and idrk what they should do quick fix/message wise) --- .../checklist/ModerationChecklist.vue | 12 +- .../ui/moderation/checklist/NodeRenderer.vue | 65 +++++++++-- .../metadata/game-version/correction.md | 1 + .../metadata/game-version/inaccurate.md | 5 + .../moderation/src/data/stages/metadata.tsx | 83 ++++++++++---- packages/moderation/src/data/stages/rules.tsx | 38 +++---- .../moderation/src/data/stages/title-slug.tsx | 6 +- .../moderation/src/data/stages/versions.tsx | 20 ++-- packages/moderation/src/types/node.ts | 107 +++++++++++++----- 9 files changed, 236 insertions(+), 101 deletions(-) create mode 100644 packages/moderation/src/data/messages/checklist/messages/metadata/game-version/correction.md create mode 100644 packages/moderation/src/data/messages/checklist/messages/metadata/game-version/inaccurate.md diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index fae659d778..9490cf1a31 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -399,6 +399,7 @@ import type { ValueNodeBuilder, } from '@modrinth/moderation' import { + ComponentNodeBuilder, createTrackedPatch, evalSegment, expandVariables, @@ -1065,13 +1066,10 @@ const checklistLive = computed>(() => { isVisible = true const active = isNodeActive(node, nodeState) const actionState = (() => { - if ( - node.type !== 'toggle' && - node.type !== 'check' && - node.type !== 'switch' && - node.type !== 'option' - ) - return localState + const isBooleanValued = + node.type === 'toggle' || + (node instanceof ComponentNodeBuilder && node._valueKind === 'boolean') + if (!isBooleanValued) return localState const childState = getBooleanChildState(nodeState) as Record return withDefaults(childState, resolveChildren(node, childState)) })() diff --git a/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue b/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue index 0041f981c5..069fbf7428 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/NodeRenderer.vue @@ -1,5 +1,6 @@