widgetti / solara

A Pure Python, React-style Framework for Scaling Your Jupyter and Web Apps
https://solara.dev
MIT License
1.62k stars 105 forks source link

Question: how to access underlying vue component #543

Closed BFAGIT closed 2 months ago

BFAGIT commented 2 months ago

Hey guys, Still exploring the library and I have a question about how to access vue component data. I do not have a lot of Vue.js knowledge so this is probably why I have not found the solution. I've looked at the vue_component example in depth but I haven't figured out how to gather the data back. I believe it will be with an event that is triggered on the change of the value i want to retrieve or maybe through the v-model ?

Here is an example of me trying to reimplement the select element with vue compoenent.

Python Script

import solara

@solara.component_vue("component.vue")
def MySelect(
    items_python=[
          'Connecticut',
          'Virgin Island',
          'Virginia',
          'Washington',
          'West Virginia',
          'Wisconsin',
          'Wyoming',
        ],
    label_python="My state selection"
    ):
    pass

 # state_selected = solara.reactive('') I guess I should have a reactive variable to pass the value from the vue compoenent

@solara.component
def Page():
    with solara.Column():
        with solara.Card():
            MySelect( )
            #MySelect(lambda data: state_selected.set(selection) ) # I guess it should be something like this but with an event provided
            solara.Markdown(f"The selected city is {}")

Vue component

<template>
  <v-select
    v-model="selected"
    :items="items_python"
    :label="label_python"
    hint="Pick your favorite state"
    @change="logSelectedOption"
  ></v-select>
</template>

<script>
  export default {
    data() {
      return {
        selected: [],
        states: [
        ],
      }
    },
  }
</script>

Thanks in advance, Kind regards BFAGIT

BFAGIT commented 2 months ago

it Seems I'm close with the following but I don't know how to access the selected element:

from typing import Callable
import numpy as np
import solara

@solara.component_vue("component.vue")
def MySelect(
    event_onChange: Callable[[list], str],
    items_python=[
          'Connecticut',
          'Virgin Island',
          'Virginia',
          'Washington',
          'West Virginia',
          'Wisconsin',
          'Wyoming',
        ],
    label_python="My state selection"
    ):
    pass

state_selected = solara.reactive('') #I guess I should have a reactive variable to pass the value from the vue compoenent

@solara.component
def Page():
    with solara.Column():
        with solara.Card():
            MySelect(event_onChange = lambda data: state_selected.set(selected) )
            solara.Markdown(f"The selected city is {state_selected}")

and

<template>
  <v-select
    v-model="selected"
    :items="items_python"
    :label="label_python"
    hint="Pick your favorite state"
    @change="onChange"
  ></v-select>
</template>

<script>
  export default {
    data() {
      return {
        selected: [],
        states: [
        ],
      }
    },
    methods: {
        onChange(event) {
            console.log(event.target.value, this.selected);
        },
    },
}
</script>
BFAGIT commented 2 months ago

OKay seems to work with:

from typing import Callable
import numpy as np
import solara

@solara.component_vue("component.vue")
def MySelect(
    event_onChange: Callable[[list], str],
    items_python=[
          'Connecticut',
          'Virgin Island',
          'Virginia',
          'Washington',
          'West Virginia',
          'Wisconsin',
          'Wyoming',
        ],
    label_python="My state selection"
    ):
    pass

state_selected = solara.reactive('') #I guess I should have a reactive variable to pass the value from the vue compoenent

@solara.component
def Page():
    with solara.Column():
        with solara.Card():
            MySelect(event_onChange = lambda data: state_selected.set(data) )
            solara.Markdown(f"The selected city is {state_selected.value}")

and

<template>
  <v-select
    v-model="selected"
    :items="items_python"
    :label="label_python"
    hint="Pick your favorite state"
    @change="onChange"
  ></v-select>
</template>

<script>
  export default {
    data() {
      return {
        selected: '',
        states: [
        ],
      }
    },
    methods: {
        onChange(event) {
            console.log(event.target.value, this.selected);
        },
    },
}
</script>

It seems to work but I do not undertsand why espcially when specifying data as the element to retrieve the selected value

iisakkirotko commented 2 months ago

Hi @BFAGIT!

I see that you already closed the issue, but maybe I can still clear up some things. The documentation for component_vue is not super clear, so great job piecing stuff together. You correctly found that arguments off the form event_function will be available automatically on the Vue side as function. There is another trick that simplifies your case, namely functions of the form on_var will be triggered when var changes.

The reason that you have to define selected in data is that otherwise selected remains undefined - you don't define it on the Python side. You can omit variable definitions in the Vue template when they're instead given to it as arguments by the Python code.

So the following code should also work for you:

import solara

@solara.component_vue("component.vue")
def MySelect(
    selected,
    on_selected,
    items_python=[
          'Connecticut',
          'Virgin Island',
          'Virginia',
          'Washington',
          'West Virginia',
          'Wisconsin',
          'Wyoming',
        ],
    label_python="My state selection"
    ):
    pass

state_selected = solara.reactive('') #I guess I should have a reactive variable to pass the value from the vue compoenent

@solara.component
def Page():
    with solara.Column():
        with solara.Card():
            MySelect(selected=state_selected.value, on_selected=state_selected.set)
            solara.Markdown(f"The selected city is {state_selected.value}")

and

<template>
    <v-select
      v-model="selected"
      :items="items_python"
      :label="label_python"
      hint="Pick your favorite state"
    ></v-select>
  </template>

As a sidenote, the typing of state_selected.set should be Callable[[str], None].

BFAGIT commented 2 months ago

Hello @iisakkirotko Thanks a lot for the reply, I got to say the on_var functions are sexy ahahah. thanks a lot

I've got an few other question if this is okay for you. (don't hesitate to tell me if i'm asking to much)

1) Why does the state_selected.value has to be passed to the selected ? is this done so that if state_selected get changed by something else outside mySelect?

I'm actually trying to build something a bit more complexe but I'm havibng some issue. The on_var function has solved the biggest issue I had about accessing the selected variable so thanks a lot.

Basically I'm trying to build a selector from a list that is displayed on the screen. Each element of the list have tags attributed to them. The user can select one of the tags and the selection is filtered according to that tag. All selected component are listed at the top. Here is the Vuetify Playground link

I here is also the two scripts i'm using Python

from typing import Callable
import numpy as np
import solara

items_python = [
        {
          'text': 'Paris',
          'tags': ['Small', 'French'],
        },
        {
          'text': 'New York',
          'tags': ['Medium', 'American'],
        },
        {
          'text': 'Beijing',
          'tags': ['Big', 'Chinese'],
        },
        {
          'text': 'Tokyo',
          'tags': ['Big', 'Japanese'],
        },
      ]

@solara.component_vue("advanced_multiselect2.vue")
def MySelect(
    selected,
    on_selected,
    items = [],
    ):
    pass

country_selected = solara.reactive([]) #I guess I should have a reactive variable to pass the value from the vue compoenent

@solara.component
def Page():
    with solara.Column():
        with solara.Card():
            MySelect(items = items_python, selected=country_selected.value, on_selected=country_selected.set)
            for index, element in enumerate(country_selected.value):
                solara.Markdown(f"The n°{index+1} selected city is {element['text']}")

Vue

<template>
  <v-card class="mx-auto" max-width="500">
    <v-toolbar color="transparent" flat>
      <v-toolbar-title>Country Choice</v-toolbar-title>
      <v-spacer></v-spacer>
    </v-toolbar>

    <v-container>
      <v-row align="center" justify="start">
        <v-col
          v-for="(selection, i) in selections"
          :key="selection.text"
          class="py-1 pe-0"
          cols="auto"
        >
          <v-chip
            :disabled="loading"
            closable
            @click:close="selected.splice(i, 1)"
          >
            {{ selection.text }}
          </v-chip>
        </v-col>
      </v-row>
      <v-divider class="my-4"></v-divider>

      <v-row align="center" justify="start">
        <v-col
          v-for="(tag, index) in uniqueTags"
          :key="`tag-${index}`"
          class="py-1 pe-0"
          cols="auto"
        >
          <v-chip
            :disabled="loading"
            :outlined="!selectedTags.includes(tag)"
            @click="toggleTagSelection(tag)"
            :class="{ 'tag-selected': selectedTags.includes(tag) }"
          >
            {{ tag }}
          </v-chip>
        </v-col>

        <v-col v-if="!allSelected" cols="12">
          <v-text-field
            ref="searchField"
            v-model="search"
            label="Search"
            hide-details
            single-line
          ></v-text-field>
        </v-col>
      </v-row>
    </v-container>

    <v-divider v-if="!allSelected"></v-divider>

    <v-list>
      <template v-for="item in filteredCategories">
        <v-list-item
          v-if="!selected.includes(item)"
          :key="item.text"
          :disabled="loading"
          @click="selected.push(item)"
        >
          <v-list-item-title v-text="item.text"></v-list-item-title>
        </v-list-item>
      </template>
    </v-list>

    <v-divider></v-divider>

    <v-card-actions>
      <v-spacer></v-spacer>
      <v-btn
        :disabled="!selected.length"
        :loading="loading"
        color="purple"
        variant="text"
        @click="reset"
      >
        Reset
      </v-btn>
    </v-card-actions>
  </v-card>
</template>

<script>
  export default {
    data: () => ({
      items: [],
      loading: false,
      search: '',
      selected: [],
      selectedTags: [],
    }),

    computed: {
      allSelected() {
        return this.selected.length === this.items.length
      },
      categories() {
        const search = this.search.toLowerCase()

        if (!search) return this.items

        return this.items.filter(item => {
          const text = item.text.toLowerCase()
          return text.indexOf(search) > -1
        })
      },
      uniqueTags() {
        const tags = new Set()
        this.items.forEach(item => {
          item.tags.forEach(tag => tags.add(tag))
        })
        return Array.from(tags)
      },
      filteredCategories() {
        if (!this.selectedTags.length) return this.categories

        return this.categories.filter(item =>
          this.selectedTags.some(selectedTag => item.tags.includes(selectedTag))
        )
      },
      selections() {
        const selections = []

        for (const selection of this.selected) {
          selections.push(selection)
        }

        return this.selected
      },
    },

    watch: {
      selected() {
        this.search = ''
      },
    },

    methods: {
      toggleTagSelection(tag) {
        const index = this.selectedTags.indexOf(tag)
        if (index >= 0) {
          this.selectedTags.splice(index, 1)
        } else {
          this.selectedTags.push(tag)
        }
      },
      reset() {
        this.loading = true
        setTimeout(() => {
          this.search = ''
          this.selected = []
          this.selectedTags = []
          this.loading = false
        }, 10)
      },
    },
  }
</script>

<style scoped>
  .tag-selected {
    background-color: black !important; 
    color: white; 
</style>

2) The chips at the top of the select countries lack the closing cross even though the option is present. This work with the exact same code in the vuetify playground see above. 3) The tags don't seem to repect the styled scoped defined in the bottom whereas in the vuetify playground it does. 4) The Reset button also doesn't seem to respect the style attributed to it when compared to vuetify playground.

This is a lot of asking so don't hesisate to tell me if this is to much. i've been troubleshooting the different elements since yesterday. Thanks and have a nice day :)

BFAGIT commented 2 months ago

Okay I think i've figured it out,( but haven't tested it out yet) this seems to be strongly linked to the fact that solara is built on vue 2 and not yet on vue 3 and same goes for the vuetify 2 components that are used by solara. I've looked into plain.html and this seems to be the case. Its seems that I've been using a mix of vue3 components with the options api.

Is there any plans on migration to vue 3 in the near future ? From what i read its a kit of a pain in the * and requires quiet extensive work.

iisakkirotko commented 2 months ago

Why does the state_selected.value has to be passed to the selected ? is this done so that if state_selected get changed by something else outside mySelect?

I assume the alternative you think of is to pass state_selected itself? This can't be done, since the reactive object isn't serializable to JSON.

I'm guessing the rest of your questions were answered by the difference in Vue versioning. Solara indeed doesn't yet fully support Vue/Vuetify 3. However, you can already make use of these with some Solara components (those where the Vuetify API didn't change), all ipyvuetify components (in the proper version), or with component_vue, by installing the pre-release versions of ipyvue and ipyvuetify, using pip install ipyvuetify --pre

BFAGIT commented 2 months ago

Hello @iisakkirotko, I can indeed confirm that all the rest of the question / issues i had was due to the difference in vue versioning. Using only vuetify 2 components solves everything. Thanks for the tip about pre-released version i'll tested out in the future.

For state_selected my question was more around why it needed to be provided as an input but this makes sense as there needs to be bidirectional communication between the solara reactive variable and the vue front-end.

Thanks for all your answers and time, I'll do a final comment in this issue to add all elements that I have working so it can potentially help someone else in the future