Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ideas & discussion on storing data across tabs, synced items, & cyclical callbacks #844

Closed
chriddyp opened this issue Jul 31, 2019 · 6 comments
Assignees

Comments

@chriddyp
Copy link
Member

A classic problem in Dash is how to persist values selected in one tab to graphs in another tab.

Currently, one workaround is to keep the tabs in the clientside layout, by rendering tabs via Method 2 here: https://dash.plot.ly/dash-core-components/tabs

However, there isn't a clientside solution for persisting values across tabs using method 1 - rendering the tab's contents via callbacks.

Here's a really long winded exploration of how this might work. Trying to solve this ended up surfacing a lot of other ideas & patterns worth considering.

At this point, I don't have any concrete recommendations, I am just sharing my explorations.


Persisting values across tabs

Here's a classic example: an app with two tabs.

The first tab contains all of the controls.

The second tab contains the output components like graphs which are computed based off of the controls from the first tab.

The code below does not work but it is how users would intuitively write an example like this.
That fact that this doesn't work is pretty subtle - the callbacks won't be fired / will crash because the inputs are no longer present on the page.

In addition, depending on the application the user may expect that the values that they have selected in the first tab remain selected when the tab is rerendered.

This might not be the case all of the time - if these were separate pages, perhaps the Dash developer would like the app to render with its initial state.
Or, perhaps they might like to give the user an option to 'refresh' the UI to the original/default state.

In summary, there are two challenges to this:

  1. Referencing the values of properties of components that are no longer rendered when firing callbacks.
  2. When re-rendering the controls, allowing the Dash developer to either initialize their values with the previously selected values or initialize the values with the defaults.

This is how users are currently writing these applications (this example does not work):

app.layout = html.Div([
    dcc.Tabs(
        id='tabs',
        tabs=[
            dcc.Tab(label='Controls', value='controls'),
            dcc.Tab(label='Model', value='model'),
        ],
        value='controls'
    ),

    html.Div(id='content')
])


@app.callback(
    Output('content', 'children'),
    [Input('tabs', 'value')])
def update_children(selected_tab):
    if selected_tab == 'controls':
        return html.Div([
            dcc.Dropdown(
                id='model',
                # ...
            ),

            dcc.Input(
                id='velocity'
                # ...
            )
        ])

    elif selected_tab == 'model':
        return html.Div([
            dcc.Graph(
                id='graph-1'
            ),
        ])

@app.callback(
    Output('graph-1', 'figure'),
    [Input('model', 'value'),     # <-- this example doesn't work because this input is no longer visible on the page when the figure is getting rendered
     Input('velocity', 'value')]
)
def update_graph_1(model, velocity):
    # ...

In this issue, I'd like to discuss different user-facing APIs to enable this type of behaviour.


One option might be "two-way synced" components. That is, components that have the same
value and update each other whenever either one of them changes.

With synced components, the Dash developer could sync the controls with a global store component:

Here's what this might look like

app.layout = html.Div([

    dcc.Store(id='store_model'),
    dcc.Store(id='store_velocity'),

    dcc.Tabs(
        id='tabs',
        tabs=[
            dcc.Tab(label='Controls', value='controls'),
            dcc.Tab(label='Model', value='model'),
        ],
        value='controls'
    ),

    html.Div(id='content')
])


@app.callback(
    Output('content', 'children'),
    [Input('tabs', 'value')])
def update_children(selected_tab):
    if selected_tab == 'controls':
        return html.Div([
            dcc.Dropdown(
                id='model',
                value='mtl'
                # ...
            ),

            dcc.Input(
                id='velocity'
                # ...
            )
        ])

    elif selected_tab == 'model':
        return html.Div([
            dcc.Graph(
                id='graph-1'
            ),
        ])

@app.callback(
    Output('graph-1', 'figure'),
    [Input('store_model', 'value'),    # <- output callback depends on the store now
     Input('store_velocity', 'value')]
)
def update_graph_1(model, velocity):
    # ...


# <-- New line - some type of sync API
app.sync(
    Sync('store_model', 'data'),
    Sync('model', 'value')
)

app.sync(
    Sync('store_velocity', 'data'),
    Sync('velocity', 'value')
)

One challenge with this approach is the initialization logic could be ambiguous.
Here's the lifecycle in this app:

  1. dcc.Store(id='store_model') is initialized and dcc.Dropdown(id='model', value='mtl') isn't rendered
  2. First tab is selected and the dropdown is rendered. It has an initial value (value='mtl') and so one could argue that dcc.Store should get updated with that value. This is similar to how callbacks get fired with the component's property values when the component is rendered: rendering is treated the same as a user triggered action.
  3. Second tab is selected. Dropdown is hidden but the store still exists. The graph callback is fired when it gets rendered with the values from the store.
  4. First tab is selected. What happens? In (2), we argued that the store should get updated when the dropdown is updated. But now it seems ambiguous - we'd almost want the store's value to populate the dropdown's value. But then we're overwriting the Dropdown's value explicitly provided in the component returned in the callback.

In order for this not to be ambiguous, we might need to introduce the notion of "default" properties. Perhaps this could be part of the Sync object?

app.sync(
    Sync(id='store_model', property='data', default=None),
    Sync('model', 'value', default='mtl')
)

That's not great though, because then the default can't be conditional.

Alternatively, the Sync property could be something provided in the layout. A new special property:

@app.callback(
    Output('content', 'children'),
    [Input('tabs', 'value')])
def update_children(selected_tab):
    if selected_tab == 'controls':
        return html.Div([
            dcc.Dropdown(
                id='model',
                value=dash.properties.Synced(  # <-- A more complex object provided in here
                    target_id='store_model',
                    target_property='data',
                    default='mtl'   # <-- This could be dynamic
                )
                # ...
            ),

            dcc.Input(
                id='velocity'
                value=dash.properties.Synced(
                    target_id='store_velocity',
                    target_property='data',
                    default=''
                )
            )
        ])

    elif selected_tab == 'model':
        return html.Div([
            dcc.Graph(
                id='graph-1'
            ),
        ])

In this case, the logic would be:

  1. If the synced component's "target"'s value is undefined (i.e. not specified), then use the default.
    In this case, the store's data property isn't defined when initializing the first tab, so the dropdown's value is used.
  2. If the synced component's "target"'s value is defined, then use that instead.
    In this case, when tab 1 is re-selected, the dropdown's value gets populated from the store.

Alternatively, the default value could be pushed into the dcc.Store:

app.layout = html.Div([

    dcc.Store(id='store_model', data='mtl'),
    dcc.Store(id='store_velocity', data=0),

    # tabs & content ...

])

@app.callback(
    Output('content', 'children'),
    [Input('tabs', 'value')])
def update_children(selected_tab):
    if selected_tab == 'controls':
        return html.Div([
            dcc.Dropdown(
                id='model',
                value=dash.properties.Synced(
                    id='store_model',
                    property='data',
                )
            ),

            dcc.Input(
                id='velocity'
                value=dash.properties.Synced(
                    id='store_velocity',
                    property='data'
                )
            )
        ])

    elif selected_tab == 'model':
        return html.Div([
            dcc.Graph(
                id='graph-1'
            ),
        ])

But in this model, the property value couldn't be dynamic.


Once potentially nice feature of using dcc.Store for this is the ability to save values
into localstorage across page loads. Lifecycle:

  1. dash.properties.Synced(...) sets a default, dcc.Store(...) is undefined
  2. dcc.Store reads the data from localstorage as part of its component lifecycle and calls setProps
  3. dcc.Dropdown gets updated with that value that just "changed" from the dcc.Store
  4. When user updates the dropdown value, that new value gets synced up with dcc.Store again

Other Synced applications

Two-way sync has come up in a few other cases:

  1. dcc.Tabs & dcc.Location - As I switch tabs, the URL could update. If I load a new URL, it could update the tab that is loaded.
app.layout = html.Div([
    dcc.Location(id='url'),
    dcc.Tabs(
        id='tabs',
        value=dash.properties.Synced(
            id='url',
            property='href'
        ),
        children=[
            dcc.Tab(value='/page-1', label='Analysis 1'),
            dcc.Tab(value='/page-2', label='Analysis 2'),
        ]
    ),
    html.Div(id='output')
])

@app.callback(
    Output('output', 'children'),
    [Input('tabs', 'value')])
def update_output(tab):
    if tab == '/page-1':
        # ...
    elif tab == '/page-1':
        # ...
  1. Providing a UI that has multiple controls that represent the same number. For example, in this "Rent or Buy" calculator, you can either enter a number or drag a slider:
    image

  2. Crossfiltering in graphs. We sort of get around this right now by having a collection of "event properties" (selectedData) that can connect to a separate graph's figure. Perhaps this logic would simpler with Synced: dcc.Graph could bubble up figure.data[].selectedids as a top-level property and these could all be synced with each other. When it changes in one graph, it would update across all graphs:

    app.layout = html.Div([
        dcc.Graph(id='graph-1', selectedids=dash.properties.Synced('graph-2', 'selectedids')),
        dcc.Graph(id='graph-2', selectedids=dash.properties.Synced('graph-3', 'selectedids')),
        dcc.Graph(id='graph-3', selectedids=dash.properties.Synced('graph-4', 'selectedids')),
        dcc.Graph(id='graph-4', selectedids=dash.properties.Synced('graph-1', 'selectedids')),
    ])

    These could even be synced up with a dash_table.DataTable.selected_row_ids:

    app.layout = html.Div([
        dcc.Graph(id='graph-1', selectedids=dash.properties.Synced('graph-2', 'selectedids')),
        dcc.Graph(id='graph-2', selectedids=dash.properties.Synced('graph-3', 'selectedids')),
        dcc.Graph(id='graph-3', selectedids=dash.properties.Synced('graph-4', 'selectedids')),
        dcc.Graph(id='graph-4', selectedids=dash.properties.Synced('datatable', 'selectedids')),
        dash_table.DataTable(id='datatable', selected_row_ids=dash.properties.Synced('graph-1', 'selectedids'))
    ])

    Writing synced properties in a "chain" like this feels a little clever, perhaps they could just be centrally tied up to a dcc.Store (both syntaxes would work, this version just might be easier to teach):

    app.layout = html.Div([
        dcc.Store(id='selected'),
    
        dcc.Graph(id='graph-1', selectedids=dash.properties.Synced('selected', 'selectedids')),
        dcc.Graph(id='graph-2', selectedids=dash.properties.Synced('selected', 'selectedids')),
        dcc.Graph(id='graph-3', selectedids=dash.properties.Synced('selected', 'selectedids')),
        dcc.Graph(id='graph-4', selectedids=dash.properties.Synced('selected', 'selectedids')),
        dash_table.DataTable(id='datatable', selected_row_ids=dash.properties.Synced('selected', 'selectedids'))
    ])

    In this case, the graphs are crossfiltered simply by the most recent selection, so no custom transformations are needed.

    In other forms of crossfiltering, the crossfiltering is the union or intersect of actions. Now it feels like we're getting back into layout-embedded clientside transformations as explored in Dash Clientside Transformations dash-renderer#142:

    app.layout = html.Div([
        dcc.Store(id='selected', selectedids=union(
            dash.properties.Input('graph-1', 'selectedids'),
            dash.properties.Input('graph-2', 'selectedids'),
            dash.properties.Input('graph-3', 'selectedids'),
            dash.properties.Input('graph-4', 'selectedids'),
            dash.properties.Input('datatable', 'selected_row_ids'),
        )),
    
        dcc.Graph(id='graph-1', selectedids=dash.properties.Input('selected', 'selectedids')),
        dcc.Graph(id='graph-2', selectedids=dash.properties.Input('selected', 'selectedids')),
        dcc.Graph(id='graph-3', selectedids=dash.properties.Input('selected', 'selectedids')),
        dcc.Graph(id='graph-4', selectedids=dash.properties.Input('selected', 'selectedids')),
        dash_table.DataTable(id='datatable', selected_row_ids=dash.properties.Input('selected', 'selectedids'))
    ])

    (Transformations might bring up some other issues - exploring these below)

  3. The community has brought this up a few times:


Some other considerations

This is about as far as I've thought so far. Here are some other questions & ideas to explore.

  • Data-* properties?
    With this API, Synced only works with top-level properties.
    dcc.Store only has a single top-level property for storing data: data
    So, if you wanted to store 10 inputs, you would need to create 10 dcc.Store components.

    It'd be nice if you could specify arbitrary properties on a single dcc.Store:

    dcc.Store(id='store'),
    
    dcc.Dropdown(value=Synced('store', 'data-model')),
    dcc.Input(value=Synced('store', 'data-velocity')),

    We have special support for aria-* and data-* properties in components, so
    maybe we could use that?

  • Transformations?
    Through careful prop design across components, many properties could be 1-1 with each other.
    However, there might be some synced components that would require a (inverse)transformation.
    For example, two input components: one in lbs, the other in kgs.
    We might be able to just allow two callbacks for arbitrary transformations:

    app.layout = html.Div([
        dcc.Input(id='lbs'),
        dcc.Input(id='kg'),
    ])
    
    @app.callback(Output('lbs', 'value'), [Input('kg', 'value')])
    def kg_to_lbs(value):
        return value * 2.2
    
    @app.callback(Output('kg', 'value'), [Input('lbs', 'value')])
    def kg_to_lbs(value):
        return value / 2.2

    Of course, if we supported these callback expressions then we wouldn't
    necessarily need the special layout property dash.properties.Synced, as
    a user could just write identity callbacks:

    app.layout = html.Div([
        dcc.Store(id='selected'),
    
        dcc.Graph(id='graph-1'),
        dcc.Graph(id='graph-2'),
        dcc.Graph(id='graph-3'),
        dcc.Graph(id='graph-4'),
        dash_table.DataTable(id='datatable')
    ])
    
    def identity(i):
        return i
    
    app.callback(Output('selected', 'data'), [Input('graph-1', 'selected_data')])(identity)
    app.callback(Output('selected', 'data'), [Input('graph-2', 'selected_data')])(identity)
    app.callback(Output('selected', 'data'), [Input('graph-3', 'selected_data')])(identity)
    app.callback(Output('selected', 'data'), [Input('graph-4', 'selected_data')])(identity)
    app.callback(Output('selected', 'data'), [Input('datatable', 'selected_row_ids')])(identity)
    
    app.callback(Output('graph-1', 'selected_data'), [Input('seleted', 'data')])(identity)
    app.callback(Output('graph-2', 'selected_data'), [Input('seleted', 'data')])(identity)
    app.callback(Output('graph-3', 'selected_data'), [Input('seleted', 'data')])(identity)
    app.callback(Output('graph-4', 'selected_data'), [Input('seleted', 'data')])(identity)
    app.callback(Output('datatable', 'selected_row_ids'), [Input('seleted', 'data')])(identity)

    And these could be written with clientside_callbacks too.

    The union/intersect version of crossfiltering would have a different cyclical transformation:

    app.layout = html.Div([
        dcc.Store(id='selected'),
    
        dcc.Graph(id='graph-1'),
        dcc.Graph(id='graph-2'),
        dcc.Graph(id='graph-3'),
        dcc.Graph(id='graph-4'),
        dash_table.DataTable(id='datatable')
    ])
    
    def intersect(*args):
        return reduce(np.intersect1d, args)
    
    def identity(i):
        return i
    
    app.callback(
        Output('selected', 'data'),
        [Input('graph-{}'.format(i)) for i in range(1, 5)] +
        [Input('datatable', 'selected_row_ids')]
    )(intersect)
    
    app.callback(Output('graph-1', 'selected_data'), [Input('selected', 'data')])(identity)
    app.callback(Output('graph-2', 'selected_data'), [Input('selected', 'data')])(identity)
    app.callback(Output('graph-3', 'selected_data'), [Input('selected', 'data')])(identity)
    app.callback(Output('graph-4', 'selected_data'), [Input('selected', 'data')])(identity)
    app.callback(Output('datatable', 'selected_row_ids'), [Input('selected', 'data')])(identity)

    So, dash.properties.Sync would just be a in-layout shorthand for the common case: 1-1, clientside syncing of properties with built-in default handling.

    Would the two-way @app.callback method be able handle the lifecycle ambiguity mentioned at the top of this post? Let's see:

    app.layout = html.Div([
    
        dcc.Store(id='store_model'),
        dcc.Store(id='store_velocity'),
    
        dcc.Tabs(
            id='tabs',
            tabs=[
                dcc.Tab(label='Controls', value='controls'),
                dcc.Tab(label='Model', value='model'),
            ],
            value='controls'
        ),
    
        html.Div(id='content')
    ])
    
    
    @app.callback(
        Output('content', 'children'),
        [Input('tabs', 'value')])
    def update_children(selected_tab):
        if selected_tab == 'controls':
            return html.Div([
                dcc.Dropdown(
                    id='model'
                ),
    
                dcc.Input(
                    id='velocity'
                )
            ])
    
        elif selected_tab == 'model':
            return html.Div([
                dcc.Graph(
                    id='graph-1'
                ),
            ])
    
    @app.callback(
        Output('store_model', 'data'),
        [Input('model', 'value')],
        [State('store_model', 'data')])
    def sync_store_from_dropdown(dropdown_value, existing_store_value):
        # This callback gets fired when the dropdown value
        # changes from user interaction _or_ gets rendered.
        #
        # What we want:
        # - If store is empty (initial page load), then sync up the store
        #   with the dropdown's value
        # - If the store isn't empty, then we're at an ambiguous place:
        #       - The user might be on tab 1 and might have just selected a
        #         new dropdown value. In that case, we need to sync.
        #       - But, if this is getting fired because the user just switched
        #         back to tab 1 and the dropdown was just rendered with its
        #         default value (via the callback), then we don't want to sync the dropdown's
        #         default value back to the store, we want the store to populate
        #         the dropdown
    
        # So, there is some detail about default values that
        # cyclical callbacks don't handle.
    
    @app.callback(
        Output('model', 'value'),
        [Input('store_model', 'data')],
        [State('model', 'value')])
    def sync_dropdown_from_store(store_value, existing_data_value):
        # ...?

    In this case, our cyclical callbacks aren't handling default values.

    Perhaps we need a notion of default values & undefined values.

    Then, our initialization routine would:

    1. Analyze the cyclical graph and look for default values & non-undefined values
    2. Start firing callbacks in a circle, starting with non-undefined values, or if they don't exist, default values.
    3. Firing everything in one complete circle:
    app.layout = html.Div([
    
        dcc.Store(id='store_model'),
        dcc.Store(id='store_velocity'),
    
        dcc.Tabs(
            id='tabs',
            tabs=[
                dcc.Tab(label='Controls', value='controls'),
                dcc.Tab(label='Model', value='model'),
            ],
            value='controls'
        ),
    
        html.Div(id='content')
    ])
    
    
    @app.callback(
        Output('content', 'children'),
        [Input('tabs', 'value')])
    def update_children(selected_tab):
        if selected_tab == 'controls':
            return html.Div([
                dcc.Dropdown(
                    id='model',
                    value=dash.properties.Default('mtl')  # <--- provide a default value
                ),
    
                dcc.Input(
                    id='velocity'
                )
            ])
    
        elif selected_tab == 'model':
            return html.Div([
                dcc.Graph(
                    id='graph-1'
                ),
            ])
    
    @app.callback(
        Output('store_model', 'data'),
        [Input('model', 'value')])
    def sync_store_from_dropdown(dropdown_value):
        return dropdown_value
    
    @app.callback(
        Output('model', 'value'),
        [Input('store_model', 'data')])
    def sync_dropdown_from_store(store_value):
        return store_value

    Lifecycle:

    1. Page is loaded, store is undefined, tab 1 is selected

    2. Dropdown is rendered. Analyze the elements in the callback graph:

      • Store is undefined
      • Dropdown has a default value

      So, since dropdown has a default, so fire the dropdown->store callback

    3. Store is updated. Assume that this callback chain does not "evolve". Assume that firing the store->dropdown callback will not change the value of the dropdown.

    4. Select the second tab.

    5. Select & render the first tab. Analyze the elements in the callback graph:

      • Store is defined
      • Dropdown has a default value

      In this case, defined values have a higher priority than default values, so fire the store->dropdown callback.

    This seems to work...

    It would probably be worth looking at the lifecycle of the other examples too. For example, in the crossfiltering/intersection example, we might want to initalize selecteddata=[] in all of the elements.

    In that case (where everything is defined), which callbacks do we fire on initialization?

    • We could assume that the properties were provided in a "consistent" state and we wouldn't fire them.

    • Or perhaps we fire all of the callbacks in a circle, so the user could provide something like:

      app.layout = html.Div([
          dcc.Store(id='selected'),
      
          dcc.Graph(id='graph-1', selected=['a', 'b']),
          dcc.Graph(id='graph-2', selected=['c', 'b']),
          dcc.Graph(id='graph-3', selected=['d', 'e']),
          dcc.Graph(id='graph-4', selected=['e', 'f']),
          dash_table.DataTable(id='datatable', selected=['a', 'g'])
      ])

      in which case, the system would evolve into:

      • Callback 1 - Graphs->Store: Store.data = union(...) = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
      • Callback 2-5 - store->graph-1, store->graph-2, ...: ['a', 'b', 'c', 'd', 'e', 'f', 'g']`
      • Circle is complete.
    • If all of the values are defined at start, then it becomes ambiguous where to start

    app.layout = html.Div([
        dcc.Store(id='selected', selected=['a', 'b']),
    
        dcc.Graph(id='graph-1', selected=['a', 'b']),
        dcc.Graph(id='graph-2', selected=['c', 'b']),
        dcc.Graph(id='graph-3', selected=['d', 'e']),
        dcc.Graph(id='graph-4', selected=['e', 'f']),
        dash_table.DataTable(id='datatable', selected=['a', 'g'])
    ])

    If we start with Graphs->Store, then we get:

      1. Callback 1 - Graphs->Store: Store.data = union(...) = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
      1. Callback 2-5 - store->graph-1, store->graph-2, ...: ['a', 'b', 'c', 'd', 'e', 'f', 'g']`

    But if we start Store->Graphs, then we get:

      1. store->graph-1, store->graph-2, ...: ['a', 'b']
      1. graphs->store: ['a', 'b']

    However, the user provided an "inconsistent" system, so perhaps this is OK.

    We could also assume that if all of the variables are defined, then the system is "consistent" and we could skip firing the initialization callbacks.

@ncorran
Copy link

ncorran commented Mar 11, 2021

I believe I have got an equivalent problem working in a Dash instance.

I have a multi-tab layout, but I have put it in a Div, and inside that Div I have 2x dcc.Location url-reader and url-writer, a Store(global-store), and a set of Store('tab'-store), plus the Tabs. Each Tab is then a dynamic layout. The key is that all the Stores are scoped outside of Tabs.

From each tab, when I get values to save (from dcc.Dropdown values etc.) I save into the Output('tab'-store).

I have a callback for Input('tab'-store) (x number of tabs), State(global-store), Output(global-store) which basically creates a Dict of {'tabname': 'tab'-store.value} which is updated for any change.

Then on any of my Tabs I can reference the State(global-store), which lets me transfer data between tabs.

The 2x dcc.Locations also allow me to update the url-writer from the global-store - so the URL provides a 'deep-link' that can select the tab and initialise the widgets on the tab with the right values, which updates the browser URL for every change (via the store), and the url-reader is watched for Input which is when someone follows / enters a deep-link, and it is the final Input to the global-store. (The cb has to check if url-reader is the ctx.triggered source, and if so update the correct part of the global-store)

Hope that all makes sense. I feel that this is a pattern that is really useful for all Tabbed Dashboards - but if its 'known' I certainly didn't find it anywhere from browsing.

@blanning19
Copy link

Do you happen to have an example of how you did this ncorran?

HammadTheOne pushed a commit to HammadTheOne/dash that referenced this issue May 28, 2021
Fix string comparison for lint:black in package.json
HammadTheOne pushed a commit to HammadTheOne/dash that referenced this issue May 28, 2021
@Jitesh17
Copy link

@chriddyp Thank you for your post.
I couldn't use dash.properties.Synced and getting the error AttributeError: module 'dash' has no attribute 'properties'.
Which version of dash contains this?
How can I implement this?
Thank you.

@ncorran
Copy link

ncorran commented Jul 15, 2021 via email

HammadTheOne pushed a commit that referenced this issue Jul 23, 2021
Fix string comparison for lint:black in package.json
@gvwilson gvwilson self-assigned this Jul 18, 2024
@gvwilson
Copy link
Contributor

@chriddyp is this one still relevant or has it gone stale? thanks - @gvwilson

@blanning19
Copy link

blanning19 commented Jul 18, 2024 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants