refactor: clean up checklist structure + node renderer

This commit is contained in:
Calum H. (IMB11)
2026-08-10 11:58:16 +01:00
parent 1a56233c96
commit eb5897b553
21 changed files with 1373 additions and 1051 deletions
@@ -1,15 +0,0 @@
import type { ActiveAction, NodeState } from '@modrinth/moderation/src/types/node'
import type { InjectionKey, Ref } from 'vue'
export interface LiveNode {
isActive: boolean
isVisible: boolean
isFixActionable: boolean
messageCount: number
fixCount: number
hasRequiredMissing: boolean
activeActions: ActiveAction[]
}
export const STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
Symbol('checklistState')
@@ -1,30 +1,37 @@
<template> <template>
<Button <IconButton
v-tooltip="tooltip" v-if="icon"
:type="color === 'standard' ? 'base' : 'colored'" :type="color === 'standard' ? 'base' : 'colored'"
:color="color === 'standard' ? undefined : color" :color="color === 'standard' ? undefined : color"
:disabled="disabled" :disabled="disabled"
:aria-label="icon ? label : undefined" :label="label"
@click="emit('update:modelValue', !modelValue)" @click="emit('update:modelValue', !modelValue)"
> >
<component :is="icon" v-if="icon" /> <component :is="icon" aria-hidden="true" />
<template v-else>{{ label }}</template> </IconButton>
<Button
v-else
:type="color === 'standard' ? 'base' : 'colored'"
:color="color === 'standard' ? undefined : color"
:disabled="disabled"
@click="emit('update:modelValue', !modelValue)"
>
{{ label }}
</Button> </Button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Button } from '@modrinth/ui' import { Button, IconButton } from '@modrinth/ui'
import type { Component } from 'vue' import type { Component } from 'vue'
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
label?: string label: string
icon?: Component icon?: Component
disabled?: boolean disabled?: boolean
needsAttention?: boolean needsAttention?: boolean
fixActionable?: boolean fixActionable?: boolean
tooltip?: Record<string, unknown>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -0,0 +1,31 @@
const DROPDOWN_TRIGGER_CHROME_PX = 16 * 2 + 10 + 20 + 2
const dropdownMinWidthCache = new Map<string, string>()
let measureElement: HTMLSpanElement | null = null
function measureLabelWidth(label: string): number {
if (typeof document === 'undefined') return 0
if (!measureElement) {
measureElement = document.createElement('span')
measureElement.className = 'min-w-0 truncate text-primary font-semibold leading-tight'
Object.assign(measureElement.style, {
position: 'absolute',
visibility: 'hidden',
whiteSpace: 'nowrap',
left: '-9999px',
top: '0',
})
document.body.appendChild(measureElement)
}
measureElement.textContent = label
return measureElement.getBoundingClientRect().width
}
export function getDropdownMinWidth(options: { label: string }[]): string {
const key = options.map((option) => option.label).join(' ')
const cached = dropdownMinWidthCache.get(key)
if (cached) return cached
const maxLabelWidth = Math.max(0, ...options.map((option) => measureLabelWidth(option.label)))
const result = `${Math.ceil(maxLabelWidth) + DROPDOWN_TRIGGER_CHROME_PX}px`
dropdownMinWidthCache.set(key, result)
return result
}
@@ -0,0 +1,157 @@
<script lang="ts" setup>
import { Button, IconButton } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import type { NodeRendererProps, RenderableValueNode } from './types'
import { useNodeRenderer } from './use-node-renderer'
defineOptions({ name: 'NodeRenderer' })
const props = defineProps<NodeRendererProps>()
const {
applyTweak,
buttonIcon,
buttonLabel,
clickButton,
childLayout,
componentProps,
containerScope,
getEffectiveValue,
getTitle,
hasCap,
hasChildrenCap,
hasIdCap,
hasValueCap,
isEnabled,
isNodeActive,
isShown,
modelProp,
needsAttention,
nodeKey,
resolveChildren,
resolveComponent,
resolveTooltip,
titleClass,
tweakEnabled,
tweakLabel,
tweakTooltip,
updateEvent,
updateValue,
valueScope,
wrappedState,
} = useNodeRenderer(props)
</script>
<template>
<div :class="[flex ? 'flex flex-wrap gap-2' : 'space-y-4', 'w-full']">
<template v-for="(item, idx) in nodes" :key="nodeKey(item, idx)">
<template v-if="typeof item !== 'object' || item === null">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isShown(item)">
<div
:class="
hasChildrenCap(item) && !hasValueCap(item)
? 'w-full'
: !getTitle(item)
? 'contents'
: undefined
"
>
<div v-if="getTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<!-- eslint-disable vue/no-v-html -- title text is author-controlled (stage definitions), not user input -->
<span
v-html="renderString(getTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="needsAttention(item)" class="text-red">*</span>
<!-- eslint-enable vue/no-v-html -->
</div>
<template v-if="hasChildrenCap(item) && !hasValueCap(item)">
<NodeRenderer
:nodes="resolveChildren(item, containerScope(item).state)"
:state="containerScope(item).state"
:write="containerScope(item).write"
:meta="meta"
:on-image-upload="onImageUpload"
:global-state="globalState"
:flex="childLayout(item) !== 'column'"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
/>
</template>
<template v-else-if="hasValueCap(item) && hasIdCap(item)">
<component
:is="resolveComponent(item as RenderableValueNode)"
v-tooltip="resolveTooltip(item)"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(value: unknown) => updateValue(item as RenderableValueNode, value)"
/>
<template
v-for="(tweak, tweakIndex) in (item as RenderableValueNode)._tweaks ?? []"
:key="`tweak-${tweakIndex}`"
>
<IconButton
v-tooltip="tweakTooltip(tweak, item as RenderableValueNode)"
:label="tweakLabel(tweak, item as RenderableValueNode)"
:disabled="!tweakEnabled(tweak, item as RenderableValueNode)"
@click="applyTweak(tweak, item as RenderableValueNode)"
>
<component :is="tweak.icon" aria-hidden="true" />
</IconButton>
</template>
</template>
<template v-else-if="hasCap(item, '_onClick') && buttonIcon(item)">
<IconButton
v-tooltip="resolveTooltip(item)"
:disabled="!isEnabled(item)"
:label="buttonLabel(item)"
@click="clickButton(item)"
>
<component :is="buttonIcon(item)" aria-hidden="true" />
</IconButton>
</template>
<template v-else-if="hasCap(item, '_onClick')">
<Button
v-tooltip="resolveTooltip(item)"
:disabled="!isEnabled(item)"
@click="clickButton(item)"
>
{{ buttonLabel(item) }}
</Button>
</template>
</div>
</template>
</template>
<template v-for="(item, idx) in nodes" :key="`children-${nodeKey(item, idx)}`">
<NodeRenderer
v-if="
typeof item === 'object' &&
item !== null &&
isShown(item) &&
hasValueCap(item) &&
hasIdCap(item) &&
hasChildrenCap(item) &&
isNodeActive(item, state[item.id], wrappedState) &&
resolveChildren(item, valueScope(item).state).length
"
:nodes="resolveChildren(item, valueScope(item).state)"
:state="valueScope(item).state"
:write="valueScope(item).write"
:meta="meta"
:on-image-upload="onImageUpload"
:global-state="globalState"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
class="w-full"
/>
</template>
</div>
</template>
@@ -0,0 +1,82 @@
import type { BuiltinRendererKey } from '@modrinth/moderation/src/types/node'
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
import type { Component } from 'vue'
import LoaderPicker from '~/components/ui/create-project-version/components/LoaderPicker.vue'
import McVersionPicker from '~/components/ui/create-project-version/components/McVersionPicker.vue'
import ActionButton from '../action-button.vue'
import type { RenderableValueNode, RendererPropsContext } from './types'
interface RendererDefinition {
component: Component
props?: (
node: RenderableValueNode,
context: RendererPropsContext,
) => Record<string, unknown>
}
const builtinRenderers = {
action: {
component: ActionButton,
props: (node, context) => ({
label: 'label' in node && typeof node.label === 'string' ? node.label : '',
icon: '_icon' in node ? node._icon : undefined,
needsAttention: context.nodeFacts.needsAttention,
fixActionable: context.nodeFacts.fixActionable,
}),
},
checkbox: {
component: Checkbox,
props: (node) => ({
label: 'label' in node && typeof node.label === 'string' ? node.label : '',
}),
},
toggle: { component: Toggle },
dropdown: {
component: Combobox,
props: (node) => {
if (!('_options' in node) || !Array.isArray(node._options)) return {}
const options = node._options as Array<{ value: string; label: string }>
const none = '_none' in node && typeof node._none === 'string' ? node._none : undefined
return {
options: [
...(none !== undefined ? [{ value: '', label: none }] : []),
...options.map((option) => ({
value: option.value,
label: option.label,
})),
],
triggerClass:
'!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]',
dropdownClass: '!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0',
}
},
},
text: {
component: StyledInput,
props: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
},
markdown: {
component: MarkdownEditor,
props: (_node, context) => ({
maxHeight: 300,
disabled: false,
headingButtons: false,
onImageUpload: context.onImageUpload,
}),
},
} satisfies Record<BuiltinRendererKey, RendererDefinition>
const customRenderers = {
'loader-picker': LoaderPicker,
'game-version-picker': McVersionPicker,
} satisfies Record<string, Component>
export function resolveNodeRenderer(node: RenderableValueNode): RendererDefinition | undefined {
if (node._renderer.type === 'custom') {
const component = customRenderers[node._renderer.key as keyof typeof customRenderers]
return component ? { component } : undefined
}
return builtinRenderers[node._renderer.type]
}
@@ -0,0 +1,43 @@
import type {
AnyNode,
ChildNode,
Configurable,
Enableable,
HasValue,
Identified,
NodeMeta,
NodePropsContext,
NodeState,
Renderable,
Tweakable,
Writer,
} from '@modrinth/moderation/src/types/node'
export type RenderableValueNode = AnyNode &
HasValue &
Identified &
Partial<Enableable> &
Renderable &
Partial<Configurable> &
Partial<Tweakable>
export interface ChecklistMeta {
metaMap: Map<object, NodeMeta>
attentionMap: Map<object, boolean>
tooltipHtml: Map<object, string>
}
export interface RendererPropsContext extends NodePropsContext {
nodeFacts: { needsAttention: boolean; fixActionable: boolean }
}
export interface NodeRendererProps {
nodes: ChildNode[]
state: Record<string, NodeState>
write: Writer
meta: ChecklistMeta
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
globalState?: Record<string, Record<string, NodeState>>
}
@@ -0,0 +1,293 @@
import type {
AnyNode,
ChildNode,
HasChildren,
HasValue,
Identified,
NodePropsContext,
NodeState,
OnChangeFn,
Reactive,
TweakDef,
Writer,
} from '@modrinth/moderation/src/types/node'
import {
childWriter,
getBooleanChildState,
getEffectiveValue,
hasCap,
hasChildrenCap,
hasIdCap,
hasOptionsCap,
hasValueCap,
isNodeActive,
isShown,
originScope,
resolve,
resolveChildren,
writeNodeValue,
withStateDefaults,
} from '@modrinth/moderation/src/types/node'
import type { Component } from 'vue'
import { computed, watchEffect } from 'vue'
import { getDropdownMinWidth } from './dropdown-width'
import { resolveNodeRenderer } from './renderers'
import type { NodeRendererProps, RenderableValueNode, RendererPropsContext } from './types'
const TOOLTIP_BASE = {
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
export function useNodeRenderer(props: NodeRendererProps) {
const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write))
function resolveComponent(node: RenderableValueNode): Component | undefined {
return resolveNodeRenderer(node)?.component
}
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 getTitle(node: object): string | undefined {
if (!hasCap(node, '_title')) return undefined
const title = node._title as Reactive<string> | undefined
if (title === undefined) return undefined
return resolve(title) || undefined
}
function needsAttention(node: object): boolean {
return props.meta.attentionMap.get(node) ?? false
}
function isFixActionable(node: object): boolean {
return props.meta.metaMap.get(node)?.isFixActionable ?? false
}
function isEnabled(node: object): boolean {
if (!hasCap(node, '_enabled') || node._enabled === undefined) return true
if (typeof node._enabled === 'function') {
return (node._enabled as (state: Record<string, NodeState>) => boolean)(wrappedState.value)
}
return resolve(node._enabled as Reactive<boolean>)
}
function toggleSetValue(node: RenderableValueNode, value: string): void {
const current = getEffectiveValue(
node,
props.state[node.id],
wrappedState.value,
) as unknown as string[]
const set = new Set(Array.isArray(current) ? current : [])
if (set.has(value)) set.delete(value)
else set.add(value)
writeNodeValue(node, props.state, props.write, Array.from(set) as never, wrappedState.value)
}
function resolveTooltip(node: object): Record<string, unknown> | undefined {
if (hasCap(node, '_tooltip')) {
const tooltip = node._tooltip as
| Reactive<string>
| ((state: Record<string, NodeState>) => string)
| undefined
if (tooltip !== undefined) {
const content =
typeof tooltip === 'function' ? tooltip(wrappedState.value) : resolve(tooltip)
if (content) return { ...TOOLTIP_BASE, content }
}
}
const html = hasCap(node, '_segments') ? props.meta.tooltipHtml.get(node) : undefined
return html ? { ...TOOLTIP_BASE, content: html, html: true } : undefined
}
function componentProps(node: RenderableValueNode): Record<string, unknown> {
const context: NodePropsContext = {
onImageUpload: props.onImageUpload,
toggleSetValue: (value) => toggleSetValue(node, value),
}
const rendererContext: RendererPropsContext = {
...context,
nodeFacts: {
needsAttention: needsAttention(node),
fixActionable: isFixActionable(node),
},
}
const dropdownStyle = hasOptionsCap(node)
? {
class: '!w-auto max-w-full',
style: {
minWidth: getDropdownMinWidth(
node._options as unknown as Array<{ label: string }>,
),
},
}
: undefined
return {
disabled: !isEnabled(node),
...dropdownStyle,
...resolveNodeRenderer(node)?.props?.(node, rendererContext),
...node._extraProps?.(context),
}
}
function containerScope(node: HasChildren & Partial<Identified>): {
state: Record<string, NodeState>
write: Writer
} {
if (hasCap(node, '_stateOrigin') && node._stateOrigin && props.globalState) {
return originScope(props.globalState, node._stateOrigin as string[])
}
if (!hasIdCap(node)) return { state: props.state, write: props.write }
const raw = props.state[node.id]
const state =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
return { state, write: childWriter(props.state, props.write, node.id) }
}
function valueScope(node: HasValue & Identified): {
state: Record<string, NodeState>
write: Writer
} {
const state = getBooleanChildState(props.state[node.id])
return { state, write: childWriter(props.state, props.write, node.id) }
}
function clickButton(node: object): void {
if (!hasCap(node, '_onClick')) return
;(node._onClick as (state: Record<string, NodeState>) => void)?.(wrappedState.value)
}
function buttonIcon(node: object): Component | undefined {
return hasCap(node, '_icon') ? (node._icon as Component | undefined) : undefined
}
function buttonLabel(node: object): string {
return hasCap(node, 'label') && typeof node.label === 'string' ? node.label : ''
}
function childLayout(node: object): 'flex' | 'column' | undefined {
if (!hasCap(node, '_layout')) return undefined
return node._layout === 'flex' || node._layout === 'column' ? node._layout : undefined
}
function tweakCurrent(node: RenderableValueNode): unknown {
return getEffectiveValue(node, props.state[node.id], wrappedState.value)
}
function tweakResult(tweak: TweakDef, node: RenderableValueNode): unknown {
return tweak.compute(tweakCurrent(node), wrappedState.value)
}
function tweakEnabled(tweak: TweakDef, node: RenderableValueNode): boolean {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined && result !== tweakCurrent(node)
}
function tweakTooltip(
tweak: TweakDef,
node: RenderableValueNode,
): Record<string, unknown> | undefined {
if (!tweakEnabled(tweak, node)) return undefined
const content = tweakResult(tweak, node)
return content ? { ...TOOLTIP_BASE, content: String(content) } : undefined
}
function tweakLabel(tweak: TweakDef, node: RenderableValueNode): string {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined ? String(result) : 'Apply suggested value'
}
function applyTweak(tweak: TweakDef, node: RenderableValueNode): void {
const result = tweakResult(tweak, node)
if (result !== null && result !== undefined) updateValue(node, result)
}
function nodeKey(item: ChildNode, index: number): string {
return typeof item === 'object' && item !== null && hasIdCap(item)
? item.id
: `n-${index}`
}
function modelProp(item: object): string {
return (item as RenderableValueNode)._modelProp
}
function updateEvent(item: object): string {
return `update:${modelProp(item)}`
}
function updateValue(item: RenderableValueNode, value: unknown): void {
const onChange = hasCap(item, '_onChange')
? (item._onChange as OnChangeFn | undefined)
: undefined
if (onChange) {
const result = onChange(value as string, { override: (override) => ({ __override: override }) })
if (result && typeof result === 'object' && '__override' in result) {
writeNodeValue(
item,
props.state,
props.write,
result.__override as never,
wrappedState.value,
)
return
}
}
writeNodeValue(item, props.state, props.write, value as never, wrappedState.value)
}
const seenOnChangeValues = new Map<object, unknown>()
watchEffect(() => {
for (const node of props.nodes) {
if (typeof node !== 'object' || node === null) continue
if (!hasCap(node, '_onChange') || !node._onChange) continue
if (!hasValueCap(node) || !hasIdCap(node) || !isShown(node as AnyNode)) continue
const value = getEffectiveValue(node, props.state[node.id], wrappedState.value)
if (seenOnChangeValues.has(node) && seenOnChangeValues.get(node) === value) continue
seenOnChangeValues.set(node, value)
const onChange = (node as RenderableValueNode)._onChange as OnChangeFn | undefined
onChange?.(value as never, { override: (override) => ({ __override: override }) })
}
})
return {
applyTweak,
buttonIcon,
buttonLabel,
clickButton,
childLayout,
componentProps,
containerScope,
getEffectiveValue,
getTitle,
hasCap,
hasChildrenCap,
hasIdCap,
hasValueCap,
isEnabled,
isNodeActive,
isShown,
modelProp,
needsAttention,
nodeKey,
resolveChildren,
resolveComponent,
resolveTooltip,
titleClass,
tweakEnabled,
tweakLabel,
tweakTooltip,
updateEvent,
updateValue,
valueScope,
wrappedState,
}
}
@@ -0,0 +1,11 @@
import type { ActiveAction } from '@modrinth/moderation/src/types/node'
export interface LiveNode {
isActive: boolean
isVisible: boolean
isFixActionable: boolean
messageCount: number
fixCount: number
hasRequiredMissing: boolean
activeActions: ActiveAction[]
}
@@ -0,0 +1,180 @@
import type { AbstractWebNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
import type {
LockAcquireResponse,
ModerationQueueService,
} from '~/services/moderation/queue.ts'
interface ChecklistLockOptions {
projectId: string
queue: ModerationQueueService
addNotification: typeof AbstractWebNotificationManager.prototype.addNotification
refreshPrefetchQueue: () => void
}
interface ChecklistLockStatus {
locked: boolean
lockedBy?: { id: string; username: string; avatar_url?: string }
lockedAt?: Date
expiresAt?: Date
expired?: boolean
isOwnLock: boolean
}
export function useChecklistLock({
projectId,
queue,
addNotification,
refreshPrefetchQueue,
}: ChecklistLockOptions) {
const status = ref<ChecklistLockStatus | null>(null)
const error = ref(false)
const timeRemaining = ref<string | null>(null)
let heartbeat: ReturnType<typeof setInterval> | null = null
let countdown: ReturnType<typeof setInterval> | null = null
function clearCountdown() {
if (countdown) {
clearInterval(countdown)
countdown = null
}
timeRemaining.value = null
}
function updateCountdown() {
if (!status.value?.lockedAt || status.value.isOwnLock) {
timeRemaining.value = null
return
}
const lockedAt = new Date(status.value.lockedAt)
const expiresAt = status.value.expiresAt
? new Date(status.value.expiresAt)
: new Date(lockedAt.getTime() + 15 * 60 * 1000)
const remainingMs = expiresAt.getTime() - Date.now()
if (remainingMs <= 0) {
timeRemaining.value = null
status.value.expired = true
clearCountdown()
return
}
const minutes = Math.floor(remainingMs / 60000)
const seconds = Math.floor((remainingMs % 60000) / 1000)
timeRemaining.value = `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function startCountdown() {
clearCountdown()
updateCountdown()
countdown = setInterval(updateCountdown, 1000)
}
function setLockedBy(result: LockAcquireResponse) {
status.value = {
locked: result.locked_by != null,
lockedBy: result.locked_by,
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
expired: result.expired,
isOwnLock: false,
}
error.value = false
if (result.locked_by) startCountdown()
else clearCountdown()
}
function handleLost(result: LockAcquireResponse) {
if (heartbeat) {
clearInterval(heartbeat)
heartbeat = null
}
setLockedBy(result)
if (result.locked_by) {
addNotification({
title: 'Lock taken over',
text: `@${result.locked_by.username} is now moderating this project.`,
type: 'warning',
})
} else {
addNotification({
title: 'Moderation lock lost',
text: 'Your lock on this project has expired. Acquire the lock again to continue.',
type: 'warning',
})
}
}
function startHeartbeat() {
if (heartbeat) clearInterval(heartbeat)
heartbeat = setInterval(async () => {
const result = await queue.refreshLock()
if (!result.success) handleLost(result)
}, 5 * 60 * 1000)
}
function handleAcquired() {
status.value = { locked: false, isOwnLock: true }
error.value = false
clearCountdown()
startHeartbeat()
refreshPrefetchQueue()
}
function handleUnavailable() {
error.value = true
status.value = { locked: false, isOwnLock: false }
clearCountdown()
addNotification({
title: 'Lock unavailable',
text: 'Could not acquire moderation lock. Others may also be moderating this project.',
type: 'warning',
})
}
async function acquire() {
const result = await queue.acquireLock(projectId)
if (result.success) handleAcquired()
else if (result.locked_by) setLockedBy(result)
else handleUnavailable()
}
async function override() {
const result = await queue.overrideLock(projectId)
if (result.success) {
addNotification({
title: 'Moderation lock overridden',
text: 'You are now moderating this project.',
type: 'success',
})
handleAcquired()
} else if (result.locked_by) {
setLockedBy(result)
} else {
handleUnavailable()
}
}
async function handleVisibilityChange() {
if (document.visibilityState !== 'visible' || !status.value?.isOwnLock) return
const result = await queue.refreshLock()
if (!result.success) {
handleLost(result)
return
}
refreshPrefetchQueue()
}
function stop() {
if (heartbeat) {
clearInterval(heartbeat)
heartbeat = null
}
clearCountdown()
}
return { acquire, error, handleVisibilityChange, override, status, stop, timeRemaining }
}
@@ -0,0 +1,88 @@
import type { Labrinth } from '@modrinth/api-client'
import { expandVariables } from '@modrinth/moderation'
import {
collectMessageNodes,
computeAttentionMap,
computeNodeMeta,
evalActiveAction,
resolveChildren,
} from '@modrinth/moderation/src/types/node'
import type {
FixBuilder,
NodeState,
StageNode,
Writer,
} from '@modrinth/moderation/src/types/node'
import { renderHighlightedString } from '@modrinth/utils'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref, watchEffect } from 'vue'
interface NodeRendererStateOptions {
currentStage: ComputedRef<StageNode>
nodeStates: Ref<Record<string, Record<string, NodeState>>>
project: Ref<Labrinth.Projects.v3.Project>
projectV2: Ref<Labrinth.Projects.v2.Project>
isFixActionable: (fixes: FixBuilder[], state: Record<string, NodeState>) => boolean
}
export function useNodeRendererState({
currentStage,
nodeStates,
project,
projectV2,
isFixActionable,
}: NodeRendererStateOptions) {
const tooltipHtml = ref(new Map<object, string>())
const state = computed(
() => (nodeStates.value[currentStage.value.id] ?? {}) as Record<string, NodeState>,
)
const nodes = computed(() => resolveChildren(currentStage.value, state.value))
const write: Writer = (id, value) => {
const stageId = currentStage.value.id
const existing = nodeStates.value[stageId]
const next: Record<string, NodeState> = existing ? { ...existing } : {}
if (value === undefined) Reflect.deleteProperty(next, id)
else next[id] = value
if (Object.keys(next).length === 0) {
if (existing !== undefined) Reflect.deleteProperty(nodeStates.value, stageId)
} else {
nodeStates.value[stageId] = next
}
}
watchEffect(async (onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
const stage = currentStage.value
const actions = collectMessageNodes(nodes.value, state.value, [stage.id])
const next = new Map<object, string>()
await Promise.all(
actions.map(async (entry) => {
try {
const raw = await evalActiveAction(entry, actions, new Set())
const expanded = expandVariables(raw, projectV2.value, project.value).trim()
next.set(
entry.node,
expanded
? `<div class="markdown-body moderation-tooltip-markdown">${renderHighlightedString(expanded)}</div>`
: '',
)
} catch {
next.set(entry.node, '')
}
}),
)
if (!cancelled) tooltipHtml.value = next
})
const meta = computed(() => {
const metaMap = computeNodeMeta(nodes.value, state.value, isFixActionable)
const attentionMap = computeAttentionMap(nodes.value, state.value, metaMap)
return { metaMap, attentionMap, tooltipHtml: tooltipHtml.value }
})
return { meta, nodes, state, write }
}
@@ -0,0 +1,144 @@
import type { NodeState, StageNode } from '@modrinth/moderation/src/types/node'
import type { ComputedRef, Ref } from 'vue'
import { ref, toRaw, watch } from 'vue'
import {
getSessionChecklistState,
patchSessionChecklistState,
} from '~/services/moderation/checklist-session-storage.ts'
import {
clearChecklistState,
loadChecklistState,
saveChecklistState,
} from '~/services/moderation/checklist-storage.ts'
export async function loadChecklistPersistence(projectId: string) {
const persistedState = import.meta.client ? await loadChecklistState(projectId) : null
const activatedStages = ref<Set<string>>(new Set(persistedState?.activatedStages ?? []))
const visitedStages = ref<Set<string>>(
new Set(
import.meta.client ? (getSessionChecklistState(projectId).visitedStages ?? []) : [],
),
)
const reviewedAnyway = ref(persistedState?.reviewAnyway ?? false)
const message = ref<string | null>(persistedState?.message ?? null)
function markStageVisited(stageId: string | undefined) {
if (!stageId || visitedStages.value.has(stageId)) return
visitedStages.value.add(stageId)
patchSessionChecklistState(projectId, {
visitedStages: [...visitedStages.value],
})
}
return {
activatedStages,
markStageVisited,
message,
persistedState,
reviewedAnyway,
visitedStages,
}
}
interface ChecklistPersistenceOptions {
projectId: string
nodeStates: Ref<Record<string, Record<string, NodeState>>>
activatedStages: Ref<Set<string>>
reviewedAnyway: Ref<boolean>
message: Ref<string | null>
currentStage: Ref<number>
currentStageNode: ComputedRef<StageNode>
firstVisibleStage: () => number
markStageVisited: (stageId: string | undefined) => void
visitCurrentStageImmediately: boolean
}
export function useChecklistPersistence({
projectId,
nodeStates,
activatedStages,
reviewedAnyway,
message,
currentStage,
currentStageNode,
firstVisibleStage,
markStageVisited,
visitCurrentStageImmediately,
}: ChecklistPersistenceOptions) {
let enabled = true
let timer: ReturnType<typeof setTimeout> | null = null
function cancelPendingSave() {
if (timer === null) return
clearTimeout(timer)
timer = null
}
function save(open: boolean, resetReviewAnyway = false) {
const rawState = toRaw(nodeStates.value)
const openValue = open || undefined
const reviewedAnywayValue = resetReviewAnyway ? undefined : reviewedAnyway.value || undefined
const stageValue =
currentStage.value !== firstVisibleStage() ? currentStageNode.value.id : undefined
const messageValue = message.value ?? undefined
const stateValue = Object.keys(rawState).length > 0 ? rawState : undefined
const activatedStagesValue =
activatedStages.value.size > 0 ? [...activatedStages.value] : undefined
if (
!openValue &&
!reviewedAnywayValue &&
!stageValue &&
!messageValue &&
!stateValue &&
!activatedStagesValue
) {
return clearChecklistState(projectId)
}
return saveChecklistState(projectId, {
...(openValue && { open: openValue }),
...(reviewedAnywayValue && { reviewAnyway: reviewedAnywayValue }),
...(stageValue && { stage: stageValue }),
...(messageValue && { message: messageValue }),
...(stateValue && { state: stateValue }),
...(activatedStagesValue && { activatedStages: activatedStagesValue }),
})
}
function persist() {
if (!enabled || !import.meta.client) return
cancelPendingSave()
timer = setTimeout(() => {
timer = null
void save(true)
}, 150)
}
async function persistImmediately(open: boolean, resetReviewAnyway = false) {
if (!import.meta.client) return
cancelPendingSave()
await save(open, resetReviewAnyway)
}
function disable() {
enabled = false
cancelPendingSave()
}
function dispose() {
cancelPendingSave()
if (enabled) void save(true)
}
watch(currentStage, persist)
watch(nodeStates, persist, { deep: true })
watch(activatedStages, persist, { deep: true })
watch(message, persist)
watch(currentStageNode, (stage) => markStageVisited(stage.id), {
immediate: visitCurrentStageImmediately,
})
return { disable, dispose, persist, persistImmediately }
}
@@ -0,0 +1,91 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ActiveAction } from '@modrinth/moderation/src/types/node'
import { createTrackedPatch, hasCap } from '@modrinth/moderation/src/types/node'
import type { FixBuilder } from '@modrinth/moderation/src/types/node/fix'
import { injectModrinthClient } from '@modrinth/ui'
import type { ProjectStatus } from '@modrinth/utils'
import { useMutation } from '@tanstack/vue-query'
import type { Ref } from 'vue'
interface ModerationSubmissionOptions {
project: Ref<Labrinth.Projects.v3.Project>
projectV2: Ref<Labrinth.Projects.v2.Project>
versions: Ref<Labrinth.Versions.v3.Version[] | null>
}
interface ModerationSubmission {
status: ProjectStatus
message: string | null
activeActions: ActiveAction[]
}
function getFixes(node: object): FixBuilder[] {
return hasCap(node, '_fixes') && Array.isArray(node._fixes)
? (node._fixes as FixBuilder[])
: []
}
function shouldApplyFixes(actions: ActiveAction[]): boolean {
return actions.some(({ node }) => hasCap(node, '_applyFixes') && node._applyFixes === true)
}
export function useModerationSubmission({
project,
projectV2,
versions,
}: ModerationSubmissionOptions) {
const client = injectModrinthClient()
return useMutation({
mutationFn: async ({ status, message, activeActions }: ModerationSubmission) => {
const projectId = projectV2.value.id
const threadId = projectV2.value.thread_id
await client.labrinth.projects_v2.edit(projectId, { status })
if (message && threadId) {
await client.labrinth.threads_v3.sendMessage(threadId, {
body: { type: 'text', body: message },
})
}
let projectFixChanges: Labrinth.Projects.v3.EditProjectRequest = {}
if (!shouldApplyFixes(activeActions)) return projectFixChanges
const { proxy: projectProxy, changes: projectChanges } = createTrackedPatch(
project.value as Labrinth.Projects.v3.EditProjectRequest,
)
for (const { node, state } of activeActions) {
for (const fix of getFixes(node)) fix._projectFn?.(projectProxy, state)
}
projectFixChanges = projectChanges()
if (Object.keys(projectFixChanges).length > 0) {
await client.labrinth.projects_v3.edit(projectId, projectFixChanges)
}
const versionFixes = activeActions.flatMap(({ node, state }) =>
getFixes(node)
.filter((fix) => fix._versionFn)
.map((fix) => ({ fix, state })),
)
if (versionFixes.length === 0 || !versions.value) return projectFixChanges
await Promise.all(
versions.value.map(async (version) => {
const { proxy, changes } = createTrackedPatch(
version as Labrinth.Versions.v3.ModifyVersionRequest,
)
for (const { fix, state } of versionFixes) {
fix._versionFn?.(proxy, state)
}
const changed = changes()
if (Object.keys(changed).length > 0) {
await client.labrinth.versions_v3.modifyVersion(version.id, changed)
}
}),
)
return projectFixChanges
},
})
}
@@ -548,9 +548,7 @@ function getModpackFiles(): {
} }
} }
defineExpose({ defineExpose({ getModpackFiles })
getModpackFiles,
})
</script> </script>
<style scoped> <style scoped>
+1 -1
View File
@@ -560,7 +560,7 @@ import { navigateTo } from '#app'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue' import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue' import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import MessageBanner from '~/components/ui/MessageBanner.vue' import MessageBanner from '~/components/ui/MessageBanner.vue'
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue' import ModerationChecklist from '~/components/ui/moderation/moderation-checklist/index.vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue' import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue' import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue'
import ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveButton.vue' import ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveButton.vue'
@@ -1,4 +1,4 @@
import type { Component, FunctionalComponent, SVGAttributes } from 'vue' import type { FunctionalComponent, SVGAttributes } from 'vue'
import { markRaw } from 'vue' import { markRaw } from 'vue'
import { Priority } from '../priority.ts' import { Priority } from '../priority.ts'
@@ -254,39 +254,43 @@ export function withOnClick<T extends object>(node: T): T & Clickable {
}) })
} }
export interface ComponentNodePropsContext { export interface NodePropsContext {
onImageUpload?: (file: File) => Promise<string> onImageUpload?: (file: File) => Promise<string>
toggleSetValue?: (value: string) => void toggleSetValue?: (value: string) => void
nodeFacts?: { needsAttention: boolean; fixActionable: boolean }
tooltip?: Record<string, unknown>
} }
export type BuiltinRendererKey =
| 'action'
| 'checkbox'
| 'toggle'
| 'dropdown'
| 'text'
| 'markdown'
export type NodeRendererDescriptor =
| { type: BuiltinRendererKey }
| { type: 'custom'; key: string }
export interface Renderable { export interface Renderable {
_component: Component | undefined _renderer: NodeRendererDescriptor
_rendererKey: string | undefined
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_modelProp: string _modelProp: string
} }
export function withComponent<T extends object>( export function withRenderer<T extends object>(
node: T, node: T,
opts: { opts: {
component?: Component renderer: NodeRendererDescriptor
rendererKey?: string
modelProp?: string modelProp?: string
componentProps?: Renderable['_componentProps']
}, },
): T & Renderable { ): T & Renderable {
return Object.assign(node, { return Object.assign(node, {
_component: opts.component, _renderer: opts.renderer,
_rendererKey: opts.rendererKey,
_componentProps: opts.componentProps,
_modelProp: opts.modelProp ?? 'modelValue', _modelProp: opts.modelProp ?? 'modelValue',
}) })
} }
export interface Configurable { export interface Configurable {
_extraProps: ((ctx: ComponentNodePropsContext) => Record<string, unknown>) | undefined _extraProps: ((ctx: NodePropsContext) => Record<string, unknown>) | undefined
} }
export function withExtraProps<T extends object>(node: T): T & Configurable { export function withExtraProps<T extends object>(node: T): T & Configurable {
@@ -1,401 +0,0 @@
<script lang="ts" setup>
import { Button, IconButton } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import type { Component } from 'vue'
import { computed, inject, watchEffect } from 'vue'
import type { AnyNode, ChildNode, HasChildren } from '../builder'
import type {
ComponentNodePropsContext,
Enableable,
HasValue,
Identified,
OnChangeFn,
TweakDef,
} from '../capabilities'
import { CHECKLIST_META_KEY } from '../context'
import type { Writer } from '../mutate'
import { childWriter, originScope, writeNodeValue } from '../mutate'
import {
getBooleanChildState,
getEffectiveValue,
hasCap,
hasChildrenCap,
hasIdCap,
hasOptionsCap,
hasValueCap,
isNodeActive,
isShown,
resolveChildren,
withStateDefaults,
} from '../resolve'
import type { NodeState, Reactive } from '../state'
import { resolve } from '../state'
import ActionButton from './ActionButton.vue'
const metaCtx = inject(CHECKLIST_META_KEY)
const props = defineProps<{
nodes: ChildNode[]
state: Record<string, NodeState>
write: Writer
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
appComponents?: Record<string, Component>
globalState?: Record<string, Record<string, NodeState>>
}>()
type RenderableValueNode = AnyNode &
HasValue &
Identified &
Partial<Enableable> & {
_component: Component | undefined
_rendererKey: string | undefined
_modelProp: string
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_extraProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
_tweaks?: TweakDef[]
}
function resolveComponent(node: RenderableValueNode): Component | undefined {
return (
node._component ?? (node._rendererKey ? props.appComponents?.[node._rendererKey] : undefined)
)
}
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 getTitle(node: object): string | undefined {
if (!hasCap(node, '_title')) return undefined
const title = node._title as Reactive<string> | undefined
if (title === undefined) return undefined
return resolve(title) || undefined
}
function needsAttention(node: object): boolean {
return metaCtx?.value.attentionMap.get(node) ?? false
}
function isFixActionable(node: object): boolean {
return metaCtx?.value.metaMap.get(node)?.isFixActionable ?? false
}
const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write))
function isEnabled(node: Partial<Enableable>): boolean {
if (node._enabled === undefined) return true
if (typeof node._enabled === 'function') return node._enabled(wrappedState.value)
return resolve(node._enabled)
}
function toggleSetValue(node: RenderableValueNode, value: string): void {
const current = getEffectiveValue(
node,
props.state[node.id],
wrappedState.value,
) as unknown as string[]
const set = new Set(Array.isArray(current) ? current : [])
if (set.has(value)) set.delete(value)
else set.add(value)
writeNodeValue(node, props.state, props.write, Array.from(set) as never, wrappedState.value)
}
const DROPDOWN_TRIGGER_CHROME_PX = 16 * 2 + 10 + 20 + 2
let measureEl: HTMLSpanElement | null = null
function measureLabelWidth(label: string): number {
if (typeof document === 'undefined') return 0
if (!measureEl) {
measureEl = document.createElement('span')
measureEl.className = 'min-w-0 truncate text-primary font-semibold leading-tight'
Object.assign(measureEl.style, {
position: 'absolute',
visibility: 'hidden',
whiteSpace: 'nowrap',
left: '-9999px',
top: '0',
})
document.body.appendChild(measureEl)
}
measureEl.textContent = label
return measureEl.getBoundingClientRect().width
}
const dropdownMinWidthCache = new Map<string, string>()
function getDropdownMinWidth(options: { label: string }[]): string {
const key = options.map((o) => o.label).join(' ')
const cached = dropdownMinWidthCache.get(key)
if (cached) return cached
const maxLabelWidth = Math.max(0, ...options.map((o) => measureLabelWidth(o.label)))
const result = `${Math.ceil(maxLabelWidth) + DROPDOWN_TRIGGER_CHROME_PX}px`
dropdownMinWidthCache.set(key, result)
return result
}
function componentProps(node: RenderableValueNode): Record<string, unknown> {
const ctx: ComponentNodePropsContext = {
onImageUpload: props.onImageUpload,
toggleSetValue: (value) => toggleSetValue(node, value),
nodeFacts: { needsAttention: needsAttention(node), fixActionable: isFixActionable(node) },
tooltip: resolveTooltip(node),
}
const dropdownStyle = hasOptionsCap(node)
? {
class: '!w-auto max-w-full',
style: { minWidth: getDropdownMinWidth(node._options as unknown as { label: string }[]) },
}
: undefined
return {
disabled: !isEnabled(node),
...dropdownStyle,
...node._componentProps?.(ctx),
...node._extraProps?.(ctx),
}
}
function containerScope(node: HasChildren & Partial<Identified>): {
state: Record<string, NodeState>
write: Writer
} {
if (hasCap(node, '_stateOrigin') && node._stateOrigin && props.globalState) {
return originScope(props.globalState, node._stateOrigin as string[])
}
if (!hasIdCap(node)) return { state: props.state, write: props.write }
const raw = props.state[node.id]
const state =
raw && typeof raw === 'object' && !(raw instanceof Set)
? (raw as Record<string, NodeState>)
: {}
return { state, write: childWriter(props.state, props.write, node.id) }
}
function valueScope(node: HasValue & Identified): {
state: Record<string, NodeState>
write: Writer
} {
const state = getBooleanChildState(props.state[node.id])
return { state, write: childWriter(props.state, props.write, node.id) }
}
const TOOLTIP_BASE = {
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
function resolveTooltip(node: object): Record<string, unknown> | undefined {
if (hasCap(node, '_tooltip')) {
const t = node._tooltip as
| Reactive<string>
| ((state: Record<string, NodeState>) => string)
| undefined
if (t !== undefined) {
const content = typeof t === 'function' ? t(wrappedState.value) : resolve(t)
if (content) return { ...TOOLTIP_BASE, content }
}
}
const hasSegments = hasCap(node, '_segments')
const html = hasSegments ? metaCtx?.value.tooltipHtml.get(node) : undefined
return html ? { ...TOOLTIP_BASE, content: html, html: true } : undefined
}
function clickButton(node: object): void {
if (!hasCap(node, '_onClick')) return
;(node._onClick as (state: Record<string, NodeState>) => void)?.(wrappedState.value)
}
function tweakCurrent(node: RenderableValueNode): unknown {
return getEffectiveValue(node, props.state[node.id], wrappedState.value)
}
function tweakResult(tweak: TweakDef, node: RenderableValueNode): unknown {
return tweak.compute(tweakCurrent(node), wrappedState.value)
}
function tweakEnabled(tweak: TweakDef, node: RenderableValueNode): boolean {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined && result !== tweakCurrent(node)
}
function tweakTooltip(
tweak: TweakDef,
node: RenderableValueNode,
): Record<string, unknown> | undefined {
if (!tweakEnabled(tweak, node)) return undefined
const content = tweakResult(tweak, node)
return content ? { ...TOOLTIP_BASE, content: String(content) } : undefined
}
function tweakLabel(tweak: TweakDef, node: RenderableValueNode): string {
const result = tweakResult(tweak, node)
return result !== null && result !== undefined ? String(result) : 'Apply suggested value'
}
function applyTweak(tweak: TweakDef, node: RenderableValueNode): void {
const result = tweakResult(tweak, node)
if (result !== null && result !== undefined) {
updateValue(node, result)
}
}
function nodeKey(item: ChildNode, idx: number): string {
return typeof item === 'object' && item !== null && hasIdCap(item) ? item.id : `n-${idx}`
}
function modelProp(item: object): string {
return (item as RenderableValueNode)._modelProp
}
function updateEvent(item: object): string {
return `update:${modelProp(item)}`
}
function updateValue(item: RenderableValueNode, v: unknown): void {
const onChange = hasCap(item, '_onChange')
? (item._onChange as OnChangeFn | undefined)
: undefined
if (onChange) {
const result = onChange(v as string, { override: (ov) => ({ __override: ov }) })
if (result && typeof result === 'object' && '__override' in result) {
writeNodeValue(item, props.state, props.write, result.__override as never, wrappedState.value)
return
}
}
writeNodeValue(item, props.state, props.write, v as never, wrappedState.value)
}
const seenOnChangeValues = new Map<object, unknown>()
watchEffect(() => {
for (const node of props.nodes) {
if (typeof node !== 'object' || node === null) continue
if (!hasCap(node, '_onChange') || !(node as { _onChange: unknown })._onChange) continue
if (!hasValueCap(node) || !hasIdCap(node)) continue
if (!isShown(node as AnyNode)) continue
const value = getEffectiveValue(node, props.state[node.id], wrappedState.value)
if (seenOnChangeValues.has(node) && seenOnChangeValues.get(node) === value) continue
seenOnChangeValues.set(node, value)
const onChange = (node as RenderableValueNode)._onChange as OnChangeFn | undefined
onChange?.(value as never, { override: (ov) => ({ __override: ov }) })
}
})
</script>
<template>
<div :class="[flex ? 'flex flex-wrap gap-2' : 'space-y-4', 'w-full']">
<template v-for="(item, idx) in nodes" :key="nodeKey(item, idx)">
<template v-if="typeof item !== 'object' || item === null">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isShown(item)">
<div
:class="
hasChildrenCap(item) && !hasValueCap(item)
? 'w-full'
: !getTitle(item)
? 'contents'
: undefined
"
>
<div v-if="getTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<!-- eslint-disable vue/no-v-html -- title text is author-controlled (stage definitions), not user input -->
<span
v-html="renderString(getTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="needsAttention(item)" class="text-red">*</span>
<!-- eslint-enable vue/no-v-html -->
</div>
<template v-if="hasChildrenCap(item) && !hasValueCap(item)">
<NodeRenderer
:nodes="resolveChildren(item, containerScope(item).state)"
:state="containerScope(item).state"
:write="containerScope(item).write"
:on-image-upload="onImageUpload"
:app-components="appComponents"
:global-state="globalState"
:flex="(item as any)._layout !== 'column'"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
/>
</template>
<template v-else-if="hasValueCap(item) && hasIdCap(item)">
<component
:is="resolveComponent(item as RenderableValueNode)"
v-if="resolveComponent(item as RenderableValueNode) === ActionButton"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
/>
<component
:is="resolveComponent(item as RenderableValueNode)"
v-else
v-tooltip="resolveTooltip(item)"
v-bind="componentProps(item as RenderableValueNode)"
:[modelProp(item)]="
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
"
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
/>
<template
v-for="(tweak, tIdx) in (item as RenderableValueNode)._tweaks ?? []"
:key="`tweak-${tIdx}`"
>
<IconButton
v-tooltip="tweakTooltip(tweak, item as RenderableValueNode)"
:label="tweakLabel(tweak, item as RenderableValueNode)"
:disabled="!tweakEnabled(tweak, item as RenderableValueNode)"
@click="applyTweak(tweak, item as RenderableValueNode)"
>
<component :is="tweak.icon" />
</IconButton>
</template>
</template>
<template v-else-if="hasCap(item, '_onClick')">
<Button
v-tooltip="resolveTooltip(item)"
:disabled="!isEnabled(item as any)"
:aria-label="(item as any)._icon ? (item as any).label : undefined"
@click="clickButton(item)"
>
<component :is="(item as any)._icon" v-if="(item as any)._icon" />
<template v-else>{{ (item as any).label }}</template>
</Button>
</template>
</div>
</template>
</template>
<template v-for="(item, idx) in nodes" :key="`children-${nodeKey(item, idx)}`">
<NodeRenderer
v-if="
typeof item === 'object' &&
item !== null &&
isShown(item) &&
hasValueCap(item) &&
hasIdCap(item) &&
hasChildrenCap(item) &&
isNodeActive(item, state[item.id], wrappedState) &&
resolveChildren(item, valueScope(item).state).length
"
:nodes="resolveChildren(item, valueScope(item).state)"
:state="valueScope(item).state"
:write="valueScope(item).write"
:on-image-upload="onImageUpload"
:app-components="appComponents"
:global-state="globalState"
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
class="w-full"
/>
</template>
</div>
</template>
@@ -1,11 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
import type { NodeMeta } from './node-meta'
export interface ChecklistMetaContext {
metaMap: Map<object, NodeMeta>
attentionMap: Map<object, boolean>
tooltipHtml: Map<object, string>
}
export const CHECKLIST_META_KEY: InjectionKey<Ref<ChecklistMetaContext>> = Symbol('checklistMeta')
+10 -53
View File
@@ -1,10 +1,6 @@
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
import { markRaw } from 'vue'
import { withAutoProps, withChildren } from './builder' import { withAutoProps, withChildren } from './builder'
import type { ComponentNodePropsContext, Configurable } from './capabilities' import type { Configurable, NodePropsContext } from './capabilities'
import { import {
withComponent,
withEditable, withEditable,
withEnabled, withEnabled,
withExtraProps, withExtraProps,
@@ -17,6 +13,7 @@ import {
withOnClick, withOnClick,
withPriority, withPriority,
withRequired, withRequired,
withRenderer,
withSelectable, withSelectable,
withShown, withShown,
withStageMeta, withStageMeta,
@@ -26,7 +23,6 @@ import {
withTweak, withTweak,
withValue, withValue,
} from './capabilities' } from './capabilities'
import ActionButton from './components/ActionButton.vue'
import { pipe } from './pipe' import { pipe } from './pipe'
import type { NodeState, NodeStateWithChildren } from './state' import type { NodeState, NodeStateWithChildren } from './state'
@@ -68,17 +64,7 @@ export function toggle(id: string, label: string) {
withFix, withFix,
withEnabled, withEnabled,
(n) => withValue(n, booleanValue), (n) => withValue(n, booleanValue),
(n) => (n) => withRenderer(n, { renderer: { type: 'action' } }),
withComponent(n, {
component: markRaw(ActionButton),
componentProps: (ctx) => ({
label: n.label,
icon: n._icon,
needsAttention: ctx.nodeFacts?.needsAttention ?? false,
fixActionable: ctx.nodeFacts?.fixActionable ?? false,
tooltip: ctx.tooltip,
}),
}),
) )
} }
@@ -108,11 +94,7 @@ export function check(id: string, label: string) {
withFix, withFix,
withEnabled, withEnabled,
(n) => withValue(n, booleanValue), (n) => withValue(n, booleanValue),
(n) => (n) => withRenderer(n, { renderer: { type: 'checkbox' } }),
withComponent(n, {
component: markRaw(Checkbox),
componentProps: () => ({ label }),
}),
withExtraProps, withExtraProps,
), ),
) )
@@ -133,7 +115,7 @@ export function toggleSwitch(id: string, label: string) {
withFix, withFix,
withEnabled, withEnabled,
(n) => withValue(n, booleanValue), (n) => withValue(n, booleanValue),
(n) => withComponent(n, { component: markRaw(Toggle) }), (n) => withRenderer(n, { renderer: { type: 'toggle' } }),
withExtraProps, withExtraProps,
), ),
) )
@@ -227,19 +209,7 @@ export function dropdown(id: string) {
(n) => withValue(n, dropdownValue), (n) => withValue(n, dropdownValue),
withNoneLabel, withNoneLabel,
withOptions, withOptions,
(n) => (n) => withRenderer(n, { renderer: { type: 'dropdown' } }),
withComponent(n, {
component: markRaw(Combobox),
componentProps: () => ({
options: [
...(n._none !== undefined ? [{ value: '', label: n._none }] : []),
...n._options.map((o) => ({ value: o.value, label: o.label })),
],
triggerClass:
'!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]',
dropdownClass: '!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0',
}),
}),
) )
} }
@@ -278,11 +248,7 @@ export function text(id: string) {
(n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }), (n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }),
(n) => withValue(n, stringValue), (n) => withValue(n, stringValue),
withTweak, withTweak,
(n) => (n) => withRenderer(n, { renderer: { type: 'text' } }),
withComponent(n, {
component: markRaw(StyledInput),
componentProps: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
}),
withExtraProps, withExtraProps,
), ),
) )
@@ -304,16 +270,7 @@ export function markdown(id: string) {
withEnabled, withEnabled,
withEditable, withEditable,
(n) => withValue(n, stringValue), (n) => withValue(n, stringValue),
(n) => (n) => withRenderer(n, { renderer: { type: 'markdown' } }),
withComponent(n, {
component: markRaw(MarkdownEditor),
componentProps: (ctx) => ({
maxHeight: 300,
disabled: false,
headingButtons: false,
onImageUpload: ctx.onImageUpload,
}),
}),
withExtraProps, withExtraProps,
), ),
) )
@@ -353,7 +310,7 @@ export function appComponent(id: string, rendererKey: string) {
withFix, withFix,
withEnabled, withEnabled,
(n) => withValue(n, stringValueBehavior), (n) => withValue(n, stringValueBehavior),
(n) => withComponent(n, { rendererKey }), (n) => withRenderer(n, { renderer: { type: 'custom', key: rendererKey } }),
withExtraProps, withExtraProps,
) )
return Object.assign(node, { return Object.assign(node, {
@@ -364,7 +321,7 @@ export function appComponent(id: string, rendererKey: string) {
) )
return this return this
}, },
props(this: Configurable, fn: (ctx: ComponentNodePropsContext) => Record<string, unknown>) { props(this: Configurable, fn: (ctx: NodePropsContext) => Record<string, unknown>) {
this._extraProps = fn this._extraProps = fn
return this return this
}, },
@@ -1,7 +1,6 @@
export * from './builder' export * from './builder'
export * from './capabilities' export * from './capabilities'
export * from './collect' export * from './collect'
export * from './context'
export * from './factories' export * from './factories'
export * from './fix' export * from './fix'
export * from './messages' export * from './messages'
+15 -26
View File
@@ -8,9 +8,9 @@ Prefer giving non-trivial components their own folder:
components/ components/
└── analytics-chart/ └── analytics-chart/
├── index.vue ├── index.vue
├── analytics-chart-header.vue ├── header.vue
├── analytics-chart-plot.vue ├── plot.vue
├── analytics-chart-data.ts ├── data.ts
└── use-analytics-chart.ts └── use-analytics-chart.ts
``` ```
@@ -37,9 +37,9 @@ Keep files that only exist to support one component inside that component's fold
``` ```
analytics-chart/ analytics-chart/
├── index.vue ├── index.vue
├── analytics-chart-header.vue ├── header.vue
├── analytics-chart-plot.vue ├── plot.vue
├── analytics-chart-tooltip.vue ├── tooltip.vue
├── chart-ranges.ts ├── chart-ranges.ts
└── use-chart-hover-state.ts └── use-chart-hover-state.ts
``` ```
@@ -60,21 +60,10 @@ Local subcomponents should still have clear names that explain their relationshi
``` ```
analytics-chart/ analytics-chart/
├── index.vue ├── index.vue
├── analytics-chart-header.vue ├── header.vue
└── analytics-chart-plot.vue └── plot.vue
``` ```
Avoid vague names that make a local component look like a standalone public component:
```
analytics-chart/
├── index.vue
├── events.vue
└── header.vue
```
If a file is local to `analytics-chart`, prefixing it with `analytics-chart-` makes that relationship clear when it appears in search results, editor tabs, and imports.
## Nesting ## Nesting
One level of nesting is usually enough. One level of nesting is usually enough.
@@ -84,22 +73,22 @@ Prefer this:
``` ```
analytics-chart/ analytics-chart/
├── index.vue ├── index.vue
├── analytics-chart-header.vue ├── header.vue
├── analytics-chart-plot.vue ├── plot.vue
├── use-chart-hover-state.ts ├── use-chart-hover-state.ts
└── use-chart-selection.ts └── use-chart-selection.ts
``` ```
Avoid this unless a local area has become large enough to justify its own module boundary: Avoid this unless a local area has become large enough to justify its own module boundary, or for example it makes sense: e.g: for subpages
``` ```
analytics-chart/ page-thing/
├── index.vue ├── index.vue
├── header/ ├── subpage-1/
│ └── index.vue │ └── index.vue
└── plot/ └── subpage-2/
├── index.vue ├── index.vue
└── use-plot-state.ts └── use-something.ts
``` ```
Subfolders are fine when they reduce real complexity, but do not create a folder for every small subcomponent by default. Deep nesting makes the file tree harder to scan and often adds duplicated names without improving ownership. Subfolders are fine when they reduce real complexity, but do not create a folder for every small subcomponent by default. Deep nesting makes the file tree harder to scan and often adds duplicated names without improving ownership.