Feat: Many moderation checklist changes (#6730)

* Rule 1.x, 3.2 and 3.3x support

* reorganize

* Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am

* Checklist Stage Title

* Make Stage Title Good

* Rule 1.x, 3.2 and 3.3x support

* reorganize

* Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am

* Checklist Stage Title

* Make Stage Title Good

* oops i forgot 3.1

* Insufficient description improvement test

* "i forgor" checklist tooltips tooltips (kinda jank but they work)
update to latest

* only use indexedDb for moderation storage, localStorage forces a bunch of cringe

* store moderation stuff separately + compact checklist stuff

* give stages actual titles + rename what was previously called titles to hints

* Title & Slug, Summary Description, Links, License, Gallery, Versions and Reupload migrated to new system™️

* put stageoptions before nodes

* do state and variablse in tooltips

* checklist fully migrated to new system, not much new has been added (yet)

* forgot to only do modpack permissions for modpacks

* Idk why I wasn't just mutating the builder

* actual node type hierarchy + action abstraction + improve message/text handling

* dropdown + "functional" quick fixes

* dropdown + "functional" quick fixes

* Allow moderators to suggest correct environment.

* remove vestigial function

* Prioritization™️

* quickest fixes in the west

* start of metadata stage + optimize checklist parsing + a couple of qol features

* futureproof™️ environment metadata + just give me project context everywhere

* tidy up after today's refactoring

* tidy up after tidying up after today's refactoring

* move loaders to metadata (from versions)

* better required display + vueify + reorganize messages + tag quick fixes + todo

* fix log spam + concat md

* more bugs

* nested state access

* update description stage, add showcase clarity

* Fix links stage mostly, and tweak metadata stage.

* fix links lag + actions on stages

* setup links bonus buttons

* Change stage order

* Update rules stage

* Move conditional stages to the top where they belong.

* fix some metadata stuff

* fix stage selector

* don't show non-english buttons on servers without english support.

* prepr

* stop linter from trying to break my markdown

* fix: some issues

* fix: lint

* not sure why but link's links weren't links

* fix quick fixes (again) (idk how they keep breaking)

* make messages only actually count as messages if they actually have content

* rename horror button to spoilers button

* Update links name to match the links settings

* re-review stage

* unbreak stage selector

* passing off version stage fixing.

* actually fix the stage selector this time

* fix showcase clarity msg

* redirect when checklist updates slug
fix initial stage not being first if first stage is only shown under certain conditions
move to a consistent naming scheme

* redirect when checklist updates slug
fix initial stage not being first if first stage is only shown under certain conditions
move to a consistent naming scheme

* ok maybe lets use kebab

* move custom description advice stage to first button.

* temp priority on corrections applied

* make metadata page go to non setting versions page

* links stage bonus buttons, src required flag, show link value in msgs, fix msg groupings.

* update re-review button

* reupload stage navigates to project Description.

* fix: link stage using page context too late

* chore: run prepr

* fix(ci): tell typo check to ignore moderation utils

* fix: pr comment if storybook fails

* make complex nodes less complex (for the most part)
fix re-review stage shown condition
new fancy message collection system
all buttons that contribute to messages now have relevant tooltips
don't store default values in checklist db

* make complex nodes less complex (for the most part)
fix re-review stage shown condition
new fancy message collection system
all buttons that contribute to messages now have relevant tooltips
don't store default values in checklist db

* maybe fixed markdown escaping?

* tweak re-review wording.

* Update license stage handling and info

* add mixed option back for env info

* fix: formatEnvironments
fix: formatEnvironments

* update metadata environment group shown

* fix: status alert priority

* fix: display suggested slug in slug/misused message

* actually navigate when we change project slug

* ok like redirect but like don't lose state when slug changes :smart:

* make checklist reset button reset only current stage if there's modifications to it instead of full checklist

* fix versions stage messages not working

* fix donation links not being in a column

* I think slug stuff is working?
also like actually 100% garunteed markdown escaping works now

* prepr

* pseudo stages no longer interact with non-pseudo stages at all
also query projects better I think?
fix repetitive message spam in links stage

* apply quick fixes before refreshing page

* differentiate reset and return to start

* remove random empty lines in some situations

* better inaccurate slug message

* slug correction input reset button resets to current slug again instead of auto

* better keybind navigation

* incorrect environment message tweak

* use environment name(s) in checklist info

* prepr

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Gravy Boat <gravy@thatgravyboat.tech>
Co-authored-by: Michael H. <michael@iptables.sh>
Co-authored-by: chyz <32403637+chyzman@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
coolbot
2026-07-20 20:52:46 +00:00
committed by GitHub
co-authored by chyzman Calum H. Gravy Boat Michael H. chyz Prospector
parent 30e23e5cc2
commit e2085c43c5
205 changed files with 4689 additions and 3574 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
<script lang="ts" setup>
import type {
BooleanNodeBuilder,
ButtonNodeBuilder,
ChildNode,
DropdownNodeBuilder,
GroupNodeBuilder,
IdentifiedNodeBuilder,
InputNodeBuilder,
LabeledNodeBuilder,
NodeState,
NodeStateWithChildren,
OverrideValue,
ValueNodeBuilder,
} from '@modrinth/moderation'
import {
evalSegment,
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
getBooleanChildState,
NodeBuilder,
resolve,
resolveChildren,
setMessageProject,
} from '@modrinth/moderation'
import {
ButtonStyled,
Checkbox,
Combobox,
injectProjectPageContext,
MarkdownEditor,
StyledInput,
} from '@modrinth/ui'
import { renderHighlightedString, renderString } from '@modrinth/utils'
import { inject, nextTick, onMounted, reactive, watchEffect } from 'vue'
import { NODE_META_KEY, STATE_KEY } from './checklist-context'
const nodeMetaMap = inject(NODE_META_KEY)
const injectedGlobalState = inject(STATE_KEY)
const { projectV3: project, projectV2 } = injectProjectPageContext()
setMessageProject(project, projectV2)
const props = defineProps<{
nodes: ChildNode[]
showContext: Record<string, NodeState>
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
parentStatePath?: string[]
}>()
function titleClass(depth: number): string {
if (depth === 0) return 'text-lg font-extrabold text-contrast'
if (depth === 1) return 'text-base font-semibold'
if (depth === 2) return 'text-sm font-semibold'
return ''
}
function isVisible(node: NodeBuilder): boolean {
if (node._shown !== undefined) return resolve(node._shown)
if (node.type === 'group') {
const children = getChildren(node as IdentifiedNodeBuilder)
return children.some((c) => !(c instanceof NodeBuilder) || isVisible(c))
}
return true
}
function isEnabled(node: IdentifiedNodeBuilder): boolean {
const e = node._enabled
if (e === undefined) return true
if (typeof e === 'function') return e(props.showContext)
return resolve(e)
}
function isButtonEnabled(node: ButtonNodeBuilder): boolean {
const enabled = node._enabled
if (enabled === undefined) return true
if (typeof enabled === 'function') return enabled(props.showContext)
return resolve(enabled)
}
function asBool(node: NodeBuilder): BooleanNodeBuilder {
return node as BooleanNodeBuilder
}
function asButton(node: NodeBuilder): ButtonNodeBuilder {
return node as ButtonNodeBuilder
}
function asIdentified(node: NodeBuilder): IdentifiedNodeBuilder {
return node as IdentifiedNodeBuilder
}
function asLabeled(node: NodeBuilder): LabeledNodeBuilder {
return node as LabeledNodeBuilder
}
function asGroup(node: NodeBuilder): GroupNodeBuilder {
return node as GroupNodeBuilder
}
function asDropdown(node: NodeBuilder): DropdownNodeBuilder {
return node as DropdownNodeBuilder
}
function asInput(node: NodeBuilder): InputNodeBuilder {
return node as InputNodeBuilder
}
function getAtPath(path: string[]): NodeState {
let current: unknown = injectedGlobalState!.value
for (const key of path) {
if (current == null || typeof current !== 'object' || current instanceof Set) return undefined
current = (current as Record<string, unknown>)[key]
}
return current as NodeState
}
function setAtPath(path: string[], value: NodeState): void {
if (path.length === 0) return
const global = injectedGlobalState!.value as unknown as Record<string, unknown>
let current = global
const stack: [Record<string, unknown>, string][] = []
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
const next = current[key]
if (!next || typeof next !== 'object' || next instanceof Set) {
if (value === undefined) return
current[key] = next !== null && next !== undefined ? { value: next } : {}
current = current[key] as Record<string, unknown>
} else {
stack.push([current, key])
current = next as Record<string, unknown>
}
}
const lastKey = path[path.length - 1]
if (value === undefined) {
Reflect.deleteProperty(current, lastKey)
for (let i = stack.length - 1; i >= 0; i--) {
const [parent, key] = stack[i]
const child = parent[key]
if (
child &&
typeof child === 'object' &&
!(child instanceof Set) &&
Object.keys(child as object).length === 0
) {
Reflect.deleteProperty(parent, key)
} else {
break
}
}
} else {
current[lastKey] = value as unknown
}
}
function getNodeState(node: IdentifiedNodeBuilder): NodeState {
return node._statePath ? getAtPath(node._statePath) : undefined
}
function setNodeState(node: IdentifiedNodeBuilder, value: NodeState): void {
if (node._statePath) setAtPath(node._statePath, value)
}
function getBooleanState(node: BooleanNodeBuilder): boolean {
const state = getNodeState(node)
if (typeof state === 'boolean') return state
if (state && typeof state === 'object' && !(state instanceof Set)) {
const v = (state as NodeStateWithChildren).value
if (typeof v === 'boolean') return v
}
const def = resolveDefault(node)
return (def as boolean | undefined) ?? false
}
function getMultiSelectState(node: IdentifiedNodeBuilder): Set<string> {
const state = getNodeState(node)
return state instanceof Set ? state : new Set<string>()
}
function getSelectState(node: IdentifiedNodeBuilder): string | undefined {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : undefined
}
function getDropdownOptions(node: DropdownNodeBuilder) {
return [
...(node._none !== undefined ? [{ value: '', label: node._none }] : []),
...visibleChildren(node).map((c) => ({
value: asIdentified(c).id!,
label: asLabeled(c).label,
})),
]
}
function getDropdownModelValue(node: DropdownNodeBuilder) {
return getSelectState(node) ?? (node._none !== undefined ? '' : undefined)
}
function toggleSelect(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const current = getSelectState(parent)
setNodeState(parent, current === child.id ? undefined : child.id)
}
function resolveDefault(node: ValueNodeBuilder): NodeState {
const d = node._defaultValue
return typeof d === 'function' ? d(props.showContext) : d
}
function getTextState(node: IdentifiedNodeBuilder): string {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : ''
}
function getNodeTitle(node: NodeBuilder): string | undefined {
if (node._title === undefined) return undefined
return resolve(node._title) || undefined
}
function getPlaceholder(node: InputNodeBuilder): string | undefined {
if (node._placeholder !== undefined) return resolve(node._placeholder)
const def = resolveDefault(node)
if (typeof def === 'string') return def
return undefined
}
function hasActionableFixes(node: IdentifiedNodeBuilder): boolean {
return nodeMetaMap?.value.get(node)?.isFixActionable ?? false
}
function hasRequiredMissingDescendants(node: IdentifiedNodeBuilder): boolean {
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
const identified = child as IdentifiedNodeBuilder
if (nodeMetaMap?.value.get(identified)?.hasRequiredMissing) return true
if (identified.id !== undefined && hasRequiredMissingDescendants(identified)) return true
}
return false
}
function nodeHasRequiredMissing(node: IdentifiedNodeBuilder): boolean {
return !!nodeMetaMap?.value.get(node)?.hasRequiredMissing || hasRequiredMissingDescendants(node)
}
function getBooleanColor(node: BooleanNodeBuilder): string {
if (!getBooleanState(node)) return 'standard'
if (hasRequiredMissingDescendants(node)) return 'orange'
return hasActionableFixes(node) ? 'blue' : 'brand'
}
function setTextState(node: IdentifiedNodeBuilder, v: string): void {
const inputNode = node as InputNodeBuilder
const result = inputNode._onChange?.(v, overrideHelpers)
if (isOverrideValue(result)) {
const ov = result.__override
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, ov === defStr ? undefined : ov || undefined)
nextTick(() => textInputRefs.get(node)?.setValue(ov))
return
}
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, v === defStr ? undefined : defStr ? v : v || undefined)
}
function handleButtonClick(node: ButtonNodeBuilder): void {
const before = new Map<NodeBuilder, string>()
for (const inputNode of textInputRefs.keys()) {
before.set(inputNode, getTextState(asIdentified(inputNode)))
}
node._onClick?.(props.showContext)
for (const [inputNode, beforeVal] of before) {
const after = getTextState(asIdentified(inputNode))
if (after !== beforeVal) setTextState(asIdentified(inputNode), after)
}
}
onMounted(() => {
for (const inputNode of textInputRefs.keys()) {
setTextState(asIdentified(inputNode), getTextState(asIdentified(inputNode)))
}
})
function toggleBoolean(node: BooleanNodeBuilder) {
const raw = getNodeState(node)
const next = !getBooleanState(node)
const defaultVal = (resolveDefault(node) as boolean | undefined) ?? false
const isDefault = next === defaultVal
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
const hasChildren = Object.keys(children).length > 0
setNodeState(
node,
isDefault && !hasChildren
? undefined
: ({ ...children, ...(isDefault ? {} : { value: next }) } as NodeState),
)
} else {
setNodeState(node, isDefault ? undefined : next)
}
}
function toggleChip(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const selected = new Set(getMultiSelectState(parent))
if (selected.has(child.id!)) {
selected.delete(child.id!)
if (child._statePath) {
setAtPath(child._statePath, undefined)
}
} else {
selected.add(child.id!)
}
setNodeState(parent, selected.size > 0 ? selected : undefined)
}
const textInputRefs = new Map<NodeBuilder, { setValue: (v: string) => void }>()
const overrideHelpers = { override: (v: string): OverrideValue => ({ __override: v }) }
function isOverrideValue(v: unknown): v is OverrideValue {
return v !== null && typeof v === 'object' && '__override' in v
}
const scopedContextFallbacks = new WeakMap<IdentifiedNodeBuilder, Record<string, NodeState>>()
function childScopedContext(child: IdentifiedNodeBuilder): Record<string, NodeState> {
if (!child._statePath) return props.showContext
const basePath = child._statePath
const state = getAtPath(basePath)
if (state && typeof state === 'object' && !(state instanceof Set)) {
return state as Record<string, NodeState>
}
const existing = scopedContextFallbacks.get(child)
if (existing) return existing
const fallback = new Proxy({} as Record<string, NodeState>, {
set(_target, key, value) {
setAtPath([...basePath, key as string], value as NodeState)
return true
},
})
scopedContextFallbacks.set(child, fallback)
return fallback
}
function getChildrenContext(node: IdentifiedNodeBuilder): Record<string, NodeState> {
if (node.type === 'dropdown') return props.showContext
if (node.type === 'group' && asGroup(node)._selectMode) return props.showContext
return childScopedContext(node)
}
function getChildren(node: IdentifiedNodeBuilder): ChildNode[] {
return resolveChildren(node, getChildrenContext(node))
}
function visibleChildren(node: IdentifiedNodeBuilder): NodeBuilder[] {
return getChildren(node).filter((c): c is NodeBuilder => c instanceof NodeBuilder && isVisible(c))
}
const tooltipHtml = reactive(new Map<NodeBuilder, string>())
function getTooltipConfig(node: NodeBuilder, state?: Record<string, NodeState>) {
const t = node._tooltip
const manual = t === undefined ? undefined : typeof t === 'function' ? t(state ?? {}) : resolve(t)
if (manual)
return {
content: manual,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
const html = tooltipHtml.get(node)
if (!html) return undefined
return {
content: html,
html: true,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
}
watchEffect(async () => {
// Read all reactive state synchronously before any await so Vue tracks dependencies
const buttonTasks: Array<{ node: BooleanNodeBuilder; state: Record<string, NodeState> }> = []
for (const node of props.nodes) {
if (!(node instanceof NodeBuilder)) continue
if (node.type === 'toggle' && isVisible(node)) {
const boolNode = asBool(node)
if (boolNode._segments.some((s) => s.type !== 'collect')) {
const nodeState = getNodeState(boolNode)
const childState =
nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)
? (() => {
const { value: _v, ...rest } = nodeState as NodeStateWithChildren &
Record<string, NodeState>
return rest
})()
: {}
buttonTasks.push({ node: boolNode, state: childState })
}
}
if (node.type === 'group' && asGroup(node)._selectMode === 'multi' && isVisible(node)) {
for (const child of visibleChildren(asIdentified(node))) {
const opt = child as IdentifiedNodeBuilder
if (opt._segments.some((s) => s.type !== 'collect')) {
const childState = getBooleanChildState(getNodeState(opt)) as Record<string, NodeState>
buttonTasks.push({ node: opt as BooleanNodeBuilder, state: childState })
}
}
}
}
async function evalCollectedChildren(node: IdentifiedNodeBuilder): Promise<string> {
let result = ''
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
if (!isVisible(child)) continue
const childNode = asIdentified(child)
if (child.type === 'group') {
const grp = asGroup(childNode)
if (grp._selectMode === 'multi') {
const selected = getMultiSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (!optNode.id || !selected.has(optNode.id)) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (grp._selectMode === 'single') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else {
result += await evalCollectedChildren(childNode)
}
} else if (child.type === 'dropdown') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (child.type === 'toggle' || child.type === 'check') {
if (!getBooleanState(asBool(childNode))) continue
result += await evalNodeTooltip(
childNode,
getBooleanChildState(getNodeState(childNode)) as Record<string, NodeState>,
)
}
}
return result
}
async function evalNodeTooltip(
node: IdentifiedNodeBuilder,
state: Record<string, NodeState>,
): Promise<string> {
let result = ''
for (const seg of node._segments) {
if (seg.type === 'collect') {
let collected = await evalCollectedChildren(node)
if (!collected.trim() && seg.fallback) {
collected = await evalSegment(seg.fallback, state, node._statePath ?? [])
}
result += collected
} else {
result += await evalSegment(seg, state, node._statePath ?? [])
}
}
return result
}
for (const { node, state } of buttonTasks) {
try {
const raw = await evalNodeTooltip(node as unknown as IdentifiedNodeBuilder, state)
const expanded = expandVariables(raw, projectV2.value, project.value, {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2.value),
...flattenProjectV3Variables(project.value),
})
const trimmed = expanded.trim()
tooltipHtml.set(
node,
trimmed
? `<div class="markdown-body moderation-tooltip-markdown">${renderHighlightedString(trimmed)}</div>`
: '',
)
} catch {
tooltipHtml.set(node, '')
}
}
})
</script>
<template>
<div :class="flex ? 'flex flex-wrap gap-2' : 'space-y-4'">
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `button-${asButton(item).label}`
: (asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type)
: typeof item === 'string'
? `s-${item}`
: `display-${idx}`
"
>
<!-- Display items: plain strings or zero-arg render functions -->
<template v-if="!(item instanceof NodeBuilder)">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isVisible(item)">
<div :class="item.type !== 'group' && !getNodeTitle(item) ? 'contents' : undefined">
<div v-if="getNodeTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<span
v-html="renderString(getNodeTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="nodeHasRequiredMissing(asIdentified(item))" class="text-red">*</span>
</div>
<!-- group -->
<template v-if="item.type === 'group'">
<!-- multi-select (chips) mode -->
<template v-if="asGroup(item)._selectMode === 'multi'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<ButtonStyled
:color="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard'
"
:circular="!!asIdentified(child)._icon"
@click="toggleChip(asIdentified(item), asIdentified(child))"
>
<button
v-tooltip="getTooltipConfig(asIdentified(child))"
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</button>
</ButtonStyled>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!) &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- single-select (button-style) mode -->
<template v-else-if="asGroup(item)._selectMode === 'single'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<ButtonStyled
:color="
getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard'
"
:circular="!!asIdentified(child)._icon"
>
<button
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
@click="toggleSelect(asIdentified(item), asIdentified(child))"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</button>
</ButtonStyled>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- plain container mode -->
<NodeRenderer
v-else
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:flex="asGroup(item)._layout !== 'column'"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
/>
</template>
<!-- dropdown -->
<template v-else-if="item.type === 'dropdown'">
<Combobox
class="!w-80"
:options="getDropdownOptions(asDropdown(item))"
:model-value="getDropdownModelValue(asDropdown(item))"
trigger-class="!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]"
dropdown-class="!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0"
@update:model-value="(v) => setNodeState(asIdentified(item), v || undefined)"
/>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- button -->
<template v-else-if="item.type === 'button'">
<ButtonStyled :circular="!!item._icon && !asButton(item).label">
<button
v-tooltip="getTooltipConfig(item, showContext)"
:disabled="!isButtonEnabled(asButton(item))"
:aria-label="item._icon && !asButton(item).label ? asButton(item).label : undefined"
@click="handleButtonClick(asButton(item))"
>
<component :is="item._icon" v-if="item._icon" />
{{ asButton(item).label }}
</button>
</ButtonStyled>
</template>
<!-- toggle -->
<template v-else-if="item.type === 'toggle'">
<ButtonStyled :color="getBooleanColor(asBool(item))" :circular="!!item._icon">
<button
v-tooltip="getTooltipConfig(asBool(item))"
:disabled="!isEnabled(asIdentified(item))"
:aria-label="item._icon ? asLabeled(item).label : undefined"
@click="toggleBoolean(asBool(item))"
>
<component :is="item._icon" v-if="item._icon" />
<template v-else>{{ asLabeled(item).label }}</template>
</button>
</ButtonStyled>
</template>
<!-- check -->
<template v-else-if="item.type === 'check'">
<Checkbox
:model-value="getBooleanState(asBool(item))"
:label="asLabeled(item).label"
:disabled="!isEnabled(asIdentified(item))"
@update:model-value="isEnabled(asIdentified(item)) && toggleBoolean(asBool(item))"
/>
</template>
<!-- text -->
<template v-else-if="item.type === 'text'">
<StyledInput
:id="`node-${asIdentified(item).id}`"
:ref="(el: any) => (el ? textInputRefs.set(item, el) : textInputRefs.delete(item))"
v-tooltip="getTooltipConfig(item, showContext)"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
autocomplete="off"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
<!-- markdown -->
<template v-else-if="item.type === 'markdown'">
<MarkdownEditor
:id="`node-${asIdentified(item).id}`"
:aria-label="asLabeled(item).label || undefined"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
:max-height="300"
:disabled="false"
:heading-buttons="false"
:on-image-upload="onImageUpload"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
</div>
</template>
</template>
<!-- children of active boolean nodes, rendered after all siblings -->
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `children-button-${asButton(item).label}`
: `children-${asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type}`
: `children-display-${idx}`
"
>
<NodeRenderer
v-if="
item instanceof NodeBuilder &&
isVisible(item) &&
(item.type === 'toggle' || item.type === 'check') &&
getBooleanState(asBool(item)) &&
getChildren(asIdentified(item)).length
"
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
class="w-full"
/>
</template>
</div>
</template>
@@ -0,0 +1,23 @@
import type { IdentifiedNodeBuilder, NodeState } from '@modrinth/moderation'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
export interface ActiveAction {
node: IdentifiedNodeBuilder
state: Record<string, NodeState>
statePath: string[]
}
export interface LiveNode {
isActive: boolean
isVisible: boolean
isFixActionable: boolean
messageCount: number
fixCount: number
hasRequiredMissing: boolean
activeActions: ActiveAction[]
}
export const NODE_META_KEY: InjectionKey<ComputedRef<Map<IdentifiedNodeBuilder, LiveNode>>> =
Symbol('nodeMeta')
export const STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
Symbol('checklistState')