Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 26 additions & 29 deletions frontend/src/components/AppComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@
v-show="showComponent"
:is="componentName"
v-bind="componentProps"
v-model="boundValue"
v-on="{ ...vModelListeners, ...componentEvents }"
:data-component-id="block.componentId"
:style="styles"
:class="classes"
v-on="componentEvents"
>
<!-- Dynamically render named slots -->
<template v-for="(slot, slotName) in block.componentSlots" :key="slotName" v-slot:[slotName]>
Expand All @@ -35,7 +34,7 @@

<script setup lang="ts">
import Block from "@/utils/block"
import { computed, onMounted, ref, useAttrs, inject, type ComputedRef, toRefs } from "vue"
import { computed, onMounted, ref, useAttrs, inject, type ComputedRef } from "vue"
import type { ComponentPublicInstance } from "vue"
import { useRouter } from "vue-router"
import { createResource } from "frappe-ui"
Expand Down Expand Up @@ -92,14 +91,34 @@ const getComponentProps = () => {
if (!props.block || props.block.isRoot()) return []

const propValues = props.block.getPropsAndAttributes()
delete propValues.modelValue

Object.entries(propValues).forEach(([propName, config]) => {
propValues[propName] = codeStore.evaluateDynamicValues(config, evaluationContext.value)
Object.entries(propValues).forEach(([propName, propValue]) => {
if (propValue?.$type === "variable") {
propValues[propName] = codeStore.getValueFromVariable(propValue.name, evaluationContext.value)
} else if (isDynamicValue(propValue)) {
propValues[propName] = codeStore.evaluateDynamicValues(propValue, evaluationContext.value)
}
})
return propValues
}

// 2-way binding
const vModelListeners = computed(() => {
if (!props.block || props.block.isRoot()) return {}

const listeners: Record<string, Function> = {}
const propValues = props.block.getPropsAndAttributes()

Object.entries(propValues).forEach(([propName, propValue]) => {
if (propValue?.$type === "variable") {
const eventName = `update:${propName}`
listeners[eventName] = (newValue: any) => {
codeStore.setValueInVariable(propValue.name, newValue, evaluationContext.value)
}
}
})
return listeners
})

const attrs = useAttrs()
const componentProps = computed(() => {
return {
Expand All @@ -116,28 +135,6 @@ const showComponent = computed(() => {
return true
})

// modelValue binding
const boundValue = computed({
get() {
const modelValue = props.block.componentProps.modelValue
if (modelValue?.$type === "variable") {
return codeStore.getValueFromVariable(modelValue.name)
} else if (isDynamicValue(modelValue)) {
return codeStore.getDynamicValue(modelValue, evaluationContext.value)
}
return modelValue
},
set(newValue) {
const modelValue = props.block.componentProps.modelValue
if (modelValue?.$type === "variable") {
codeStore.setValueInVariable(modelValue.name, newValue)
} else {
// update the prop directly if not bound to a variable
props.block.setProp("modelValue", newValue)
}
},
})

// events
const router = useRouter()

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AppLayout/Repeater.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
>
{{ emptyStateMessage || "No data" }}
</div>
<template v-else v-for="(dataItem, dataIndex) in data" :key="dataItem?.[dataKey]">
<template v-else v-for="(dataItem, dataIndex) in data" :key="dataItem?.[dataKey] || dataIndex">
<RepeaterContextProvider :dataItem="dataItem" :dataIndex="dataIndex" :dataKey="dataKey">
<slot v-bind="{ dataItem, dataKey, dataIndex }"></slot>
</RepeaterContextProvider>
Expand Down
76 changes: 46 additions & 30 deletions frontend/src/components/DynamicValueSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,48 @@
size="sm"
:options="dynamicValueOptions"
class="!w-auto"
placement="left-start"
modelValue=""
@update:modelValue="(option: VariableOption) => emit('update:modelValue', option.value)"
@update:modelValue="(option: VariableOption) => emit('update:modelValue', option.value, bindVariable)"
>
<template #target="{ togglePopover }">
<slot name="target" v-if="$slots.target" v-bind="{ togglePopover }"></slot>
<Tooltip v-else text="Click to set dynamic value" placement="bottom">
<FeatherIcon
ref="dropdownTrigger"
name="plus-circle"
class="mr-1 h-3 w-4 cursor-pointer select-none text-ink-gray-5 outline-none hover:text-ink-gray-9"
@click="togglePopover"
/>
</Tooltip>
<IconButton
v-if="bindVariable"
:icon="Link2"
label="Synced with variable. Click to change."
placement="bottom"
class="mr-1"
@click="togglePopover"
/>
<IconButton
v-else
icon="plus-circle"
label="Click to set dynamic value"
placement="bottom"
class="mr-1"
size="sm"
@click="togglePopover"
/>
</template>

<template #item-suffix="{ option }">
<span class="text-ink-gray-4">{{ option.type?.toLowerCase() }}</span>
</template>
<template #footer>
<div class="flex items-center p-2" @mousedown.prevent>
<Tooltip text="Changing the selected variable value will change the prop value and vice versa">
<FeatherIcon name="info" class="size-3 text-ink-gray-5" />
</Tooltip>
<Switch v-model="bindVariable" label="Sync with variable" class="w-full" />
</div>
</template>
</Autocomplete>
</template>

<script setup lang="ts">
import { computed } from "vue"
import { Autocomplete, Tooltip } from "frappe-ui"
import { computed, ref, watch } from "vue"
import { Autocomplete, Switch, Tooltip } from "frappe-ui"
import IconButton from "@/components/IconButton.vue"
import useStudioStore from "@/stores/studioStore"
import useCanvasStore from "@/stores/canvasStore"
import useComponentEditorStore from "@/stores/componentEditorStore"
Expand All @@ -35,24 +53,25 @@ import type { VariableOption } from "@/types/Studio/StudioPageVariable"
import type { ComponentInput } from "@/types/Studio/StudioComponent"
import { isObjectEmpty } from "@/utils/helpers"
import useCodeStore from "@/stores/codeStore"
import Link2 from "~icons/lucide/link-2"

const props = withDefaults(defineProps<{ block?: Block; formatValuesAsTemplate?: boolean }>(), {
formatValuesAsTemplate: true,
})
const props = defineProps<{ block?: Block; isVariableBound?: string | null }>()
const emit = defineEmits<{
(event: "update:modelValue", value: string): void
(event: "update:modelValue", value: string, bindVariable: boolean): void
}>()
const bindVariable = ref(!!props.isVariableBound)

watch(
() => props.isVariableBound,
(newValue) => {
bindVariable.value = !!newValue
},
)

const store = useStudioStore()
const canvasStore = useCanvasStore()
const codeStore = useCodeStore()

const formatValue = (value: string) => {
if (props.formatValuesAsTemplate) {
return `{{ ${value} }}`
}
return value
}

const dynamicValueOptions = computed(() => {
const groups = []

Expand All @@ -63,7 +82,7 @@ const dynamicValueOptions = computed(() => {
const componentContext: VariableOption[] = []
componentInputs.map?.((input: ComponentInput) => {
componentContext.push({
value: formatValue(`inputs.${input.input_name}`),
value: `inputs.${input.input_name}`,
label: `inputs.${input.input_name}`,
type: input.type,
})
Expand All @@ -78,10 +97,7 @@ const dynamicValueOptions = computed(() => {
if (store.variableOptions.length > 0) {
groups.push({
group: "Variables",
items: store.variableOptions.map((option) => ({
...option,
value: formatValue(option.value),
})),
items: store.variableOptions,
})
}
// Data Sources group
Expand All @@ -91,7 +107,7 @@ const dynamicValueOptions = computed(() => {
? `${resourceName}.doc`
: `${resourceName}.data`
return {
value: formatValue(completion),
value: completion,
label: resourceName,
type: "array",
}
Expand All @@ -108,7 +124,7 @@ const dynamicValueOptions = computed(() => {
const repeaterContext = props.block?.repeaterDataItem
if (!isObjectEmpty(repeaterContext)) {
const repeaterOptions = Object.keys(repeaterContext!).map((key) => ({
value: formatValue(`dataItem.${key}`),
value: `dataItem.${key}`,
label: `dataItem.${key}`,
type: typeof repeaterContext![key],
}))
Expand Down
93 changes: 27 additions & 66 deletions frontend/src/components/PropsEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,19 @@
<div v-else class="mt-3 flex flex-col gap-3">
<div v-for="(config, propName) in componentProps" :key="propName" class="group flex w-full items-center">
<DynamicValueSelector
v-if="propName === 'modelValue'"
v-if="!isTestingComponent"
:block="block"
@update:modelValue="(value) => bindVariable(propName, value)"
@update:modelValue="(value, bindVariable) => setDynamicValue(propName, value, bindVariable)"
:class="{ 'mt-1 self-start': isCodeField(config.inputType) }"
:formatValuesAsTemplate="false"
>
<template #target="{ togglePopover }">
<IconButton
:icon="isVariableBound(config.modelValue) ? Link2Off : Link2"
:label="isVariableBound(config.modelValue) ? 'Disable sync with variable' : 'Sync with variable'"
placement="bottom"
class="mr-1"
@click="
() => {
if (isVariableBound(config.modelValue)) {
unbindVariable(propName)
} else {
togglePopover()
}
}
"
/>
</template>
</DynamicValueSelector>

<DynamicValueSelector
v-else-if="!isTestingComponent"
:block="block"
:class="{ 'mt-1 self-start': isCodeField(config.inputType) }"
@update:modelValue="(value) => props.block?.setProp(propName, value)"
:isVariableBound="isVariableBound(config.modelValue)"
/>

<Code
v-if="config.inputType === 'html'"
:label="propName"
language="html"
:modelValue="config.modelValue"
@update:modelValue="(newValue) => props.block?.setProp(propName, newValue)"
:modelValue="getFormattedValue(propName)"
@update:modelValue="(newValue) => handlePropUpdate(propName, newValue)"
:required="config.required"
:completions="(context: CompletionContext) => getCompletions(context, block?.getCompletions())"
:showLineNumbers="false"
Expand All @@ -62,30 +37,21 @@
v-else-if="config.inputType === 'code'"
:label="propName"
language="javascript"
:modelValue="config.modelValue"
@update:modelValue="(newValue) => props.block?.setProp(propName, newValue)"
:modelValue="getFormattedValue(propName)"
@update:modelValue="(newValue) => handlePropUpdate(propName, newValue)"
:required="config.required"
:completions="(context: CompletionContext) => getCompletions(context, block?.getCompletions())"
:showLineNumbers="false"
class="overflow-hidden"
/>
<InlineInput
v-else-if="propName !== 'modelValue'"
:label="propName"
:type="config.inputType"
:options="config.options"
:required="config.required"
:modelValue="config.modelValue"
@update:modelValue="(newValue) => props.block?.setProp(propName, newValue)"
class="flex-1"
/>
<InlineInput
v-else-if="propName === 'modelValue'"
v-else
:label="propName"
:type="config.inputType"
:options="config.options"
:required="config.required"
v-model="boundValue"
:modelValue="getFormattedValue(propName)"
@update:modelValue="(newValue) => handlePropUpdate(propName, newValue)"
class="flex-1"
/>
</div>
Expand All @@ -99,9 +65,6 @@ import Block from "@/utils/block"

import InlineInput from "@/components/InlineInput.vue"
import { isObjectEmpty } from "@/utils/helpers"
import IconButton from "@/components/IconButton.vue"
import Link2 from "~icons/lucide/link-2"
import Link2Off from "~icons/lucide/link-2-off"
import Code from "@/components/Code.vue"
import { useStudioCompletions } from "@/utils/useStudioCompletions"
import type { CompletionContext } from "@codemirror/autocomplete"
Expand Down Expand Up @@ -201,29 +164,27 @@ const isCodeField = (inputType: string) => {
return ["code", "html"].includes(inputType)
}

// variable binding
const boundValue = computed({
get() {
const modelValue = props.block?.componentProps.modelValue
if (modelValue?.$type === "variable") {
return `{{ ${modelValue.name} }}`
}
return modelValue
},
set(newValue) {
props.block?.setProp("modelValue", newValue)
},
})
function setDynamicValue(propName: string, varName: string, bindVariable: boolean) {
if (bindVariable) {
props.block?.setProp(propName, { $type: "variable", name: varName })
} else {
props.block?.setProp(propName, `{{ ${varName} }}`)
}
}

const isVariableBound = (value: any) => {
return value?.$type === "variable" ? value.name : null
const getFormattedValue = (propName: string) => {
const value = props.block?.componentProps[propName]
if (value?.$type === "variable") {
return `{{ ${value.name} }}`
}
return value
}

const bindVariable = (propName: string, varName: string) => {
props.block?.setProp(propName, { $type: "variable", name: varName })
const handlePropUpdate = (propName: string, newValue: any) => {
props.block?.setProp(propName, newValue)
}

const unbindVariable = (propName: string) => {
props.block?.setProp(propName, "")
const isVariableBound = (value: any) => {
return value?.$type === "variable" ? value.name : null
}
</script>
Loading