refactor: improve Vue widget type safety and runtime prop handling

- Add proper type guards for all widget input specs (Color, TreeSelect, MultiSelect, FileUpload, Galleria)
- Enhance schemas with missing properties (format, placeholder, accept, extensions, tooltip)
- Fix widgets to honor runtime props like disabled while accessing spec metadata
- Eliminate all 'as any' usage in widget components with proper TypeScript types
- Clean separation: widget.spec.options for metadata, widget.options for runtime state
- Refactor devtools into modular structure with vue_widgets showcase nodes
This commit is contained in:
bymyself
2025-11-06 13:31:27 -07:00
parent e0e3612588
commit 7cfa213fc8
12 changed files with 699 additions and 41 deletions

View File

@@ -27,6 +27,7 @@
import ColorPicker from 'primevue/colorpicker'
import { computed, ref, watch } from 'vue'
import { isColorInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil'
import type { ColorFormat, HSB } from '@/utils/colorUtil'
@@ -51,18 +52,18 @@ const emit = defineEmits<{
}>()
const format = computed<ColorFormat>(() => {
const optionFormat = props.widget.options?.format
const spec = props.widget.spec
if (!spec || !isColorInputSpec(spec)) {
return 'hex'
}
const optionFormat = spec.options?.format
return isColorFormat(optionFormat) ? optionFormat : 'hex'
})
type PickerValue = string | HSB
const localValue = ref<PickerValue>(
toHexFromFormat(
props.modelValue || '#000000',
isColorFormat(props.widget.options?.format)
? props.widget.options.format
: 'hex'
)
toHexFromFormat(props.modelValue || '#000000', format.value)
)
watch(

View File

@@ -176,7 +176,11 @@
ref="fileInputRef"
type="file"
class="hidden"
:accept="widget.options?.accept"
:accept="
widget.spec && isFileUploadInputSpec(widget.spec)
? widget.spec.options?.accept
: undefined
"
:aria-label="`${$t('g.upload')} ${widget.name || $t('g.file')}`"
:multiple="false"
@change="handleFileChange"
@@ -190,6 +194,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { isFileUploadInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const { widget, modelValue } = defineProps<{

View File

@@ -53,6 +53,7 @@ import Galleria from 'primevue/galleria'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { isGalleriaInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
GALLERIA_EXCLUDED_PROPS,
@@ -78,9 +79,9 @@ const activeIndex = ref(0)
const { t } = useI18n()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
)
const filteredProps = computed(() => {
return filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
})
const galleryImages = computed(() => {
if (!value.value || !Array.isArray(value.value)) return []
@@ -100,16 +101,22 @@ const galleryImages = computed(() => {
})
const showThumbnails = computed(() => {
const spec = props.widget.spec
if (!spec || !isGalleriaInputSpec(spec)) {
return galleryImages.value.length > 1
}
return (
props.widget.options?.showThumbnails !== false &&
galleryImages.value.length > 1
spec.options?.showThumbnails !== false && galleryImages.value.length > 1
)
})
const showNavButtons = computed(() => {
const spec = props.widget.spec
if (!spec || !isGalleriaInputSpec(spec)) {
return galleryImages.value.length > 1
}
return (
props.widget.options?.showItemNavigators !== false &&
galleryImages.value.length > 1
spec.options?.showItemNavigators !== false && galleryImages.value.length > 1
)
})
</script>

View File

@@ -23,6 +23,7 @@ import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { isMultiSelectInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
@@ -57,19 +58,20 @@ const MULTISELECT_EXCLUDED_PROPS = [
'overlayStyle'
] as const
// Extract spec options directly
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS),
...transformCompatProps.value
}))
// Extract multiselect options from widget options
// Extract multiselect options from widget spec options
const multiSelectOptions = computed((): T[] => {
const options = props.widget.options
if (Array.isArray(options?.values)) {
return options.values
const spec = props.widget.spec
if (!spec || !isMultiSelectInputSpec(spec)) {
return []
}
return []
const values = spec.options?.values
return Array.isArray(values) ? (values as T[]) : []
})
</script>

View File

@@ -2,7 +2,7 @@
<WidgetLayoutField :widget="widget">
<FormSelectButton
v-model="localValue"
:options="widget.options?.values || []"
:options="selectOptions"
class="w-full"
@update:model-value="onChange"
/>
@@ -10,7 +10,10 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import { isSelectButtonInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import FormSelectButton from './form/FormSelectButton.vue'
@@ -31,4 +34,13 @@ const { localValue, onChange } = useStringWidgetValue(
props.modelValue,
emit
)
// Extract spec options directly
const selectOptions = computed(() => {
const spec = props.widget.spec
if (!spec || !isSelectButtonInputSpec(spec)) {
return []
}
return spec.options?.values || []
})
</script>

View File

@@ -20,11 +20,8 @@ import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { isTreeSelectInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -57,15 +54,29 @@ const { localValue, onChange } = useWidgetValue({
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
const combinedProps = computed(() => {
const spec = props.widget.spec
if (!spec || !isTreeSelectInputSpec(spec)) {
return {
...props.widget.options,
...transformCompatProps.value
}
}
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS),
...transformCompatProps.value
}))
const specOptions = spec.options || {}
return {
// Include runtime props like disabled
...props.widget.options,
// PrimeVue TreeSelect expects 'options' to be an array of tree nodes
options: (specOptions.values as TreeNode[]) || [],
// Convert 'multiple' to PrimeVue's 'selectionMode'
selectionMode: (specOptions.multiple ? 'multiple' : 'single') as
| 'single'
| 'multiple'
| 'checkbox',
// Pass through other props like placeholder
placeholder: specOptions.placeholder,
...transformCompatProps.value
}
})
</script>

View File

@@ -45,7 +45,8 @@ const zColorInputSpec = zBaseInputOptions.extend({
isOptional: z.boolean().optional(),
options: z
.object({
default: z.string().optional()
default: z.string().optional(),
format: z.enum(['hex', 'rgb', 'hsl', 'hsb']).optional()
})
.optional()
})
@@ -54,7 +55,13 @@ const zFileUploadInputSpec = zBaseInputOptions.extend({
type: z.literal('FILEUPLOAD'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
options: z
.object({
accept: z.string().optional(),
extensions: z.array(z.string()).optional(),
tooltip: z.string().optional()
})
.optional()
})
const zImageInputSpec = zBaseInputOptions.extend({
@@ -89,7 +96,8 @@ const zTreeSelectInputSpec = zBaseInputOptions.extend({
options: z
.object({
multiple: z.boolean().optional(),
values: z.array(z.unknown()).optional()
values: z.array(z.unknown()).optional(),
placeholder: z.string().optional()
})
.optional()
})
@@ -123,7 +131,9 @@ const zGalleriaInputSpec = zBaseInputOptions.extend({
isOptional: z.boolean().optional(),
options: z
.object({
images: z.array(z.string()).optional()
images: z.array(z.string()).optional(),
showThumbnails: z.boolean().optional(),
showItemNavigators: z.boolean().optional()
})
.optional()
})
@@ -262,3 +272,39 @@ export const isChartInputSpec = (
): inputSpec is ChartInputSpec => {
return inputSpec.type === 'CHART'
}
export const isTreeSelectInputSpec = (
inputSpec: InputSpec
): inputSpec is TreeSelectInputSpec => {
return inputSpec.type === 'TREESELECT'
}
export const isSelectButtonInputSpec = (
inputSpec: InputSpec
): inputSpec is SelectButtonInputSpec => {
return inputSpec.type === 'SELECTBUTTON'
}
export const isMultiSelectInputSpec = (
inputSpec: InputSpec
): inputSpec is MultiSelectInputSpec => {
return inputSpec.type === 'MULTISELECT'
}
export const isGalleriaInputSpec = (
inputSpec: InputSpec
): inputSpec is GalleriaInputSpec => {
return inputSpec.type === 'GALLERIA'
}
export const isColorInputSpec = (
inputSpec: InputSpec
): inputSpec is ColorInputSpec => {
return inputSpec.type === 'COLOR'
}
export const isFileUploadInputSpec = (
inputSpec: InputSpec
): inputSpec is FileUploadInputSpec => {
return inputSpec.type === 'FILEUPLOAD'
}