elmish / Elmish.WPF

Static WPF views for elmish programs
Other
431 stars 71 forks source link

[SubModelSelectItem] - Cascading Listboxes : how to do it in an ItemsControl? #618

Open YkTru opened 1 week ago

YkTru commented 1 week ago

Here's a working example of hardcoded cascading ListBoxes using subModelSelectedItem:

https://github.com/user-attachments/assets/2734db15-dba4-4cd1-ad7c-7c895ede8ff9

GitHub Source: https://github.com/YkTru/Cascading-ListBoxes---SubModelSelectedItem (Some code was quickly assembled and includes GPT-generated segments, but it seems to work as expected.)


Problem: The ListBoxes are currently hardcoded (levels 0-1-2) to demonstrate the intended behavior. However, I need a dynamic, n-depth tree structure.

Goal: I’m aiming for an ItemsControl that can dynamically create ListBoxes based on selected items or added children, supporting an n-level depth.

What I Tried: I experimented a lot with recursive bindings and updates, but none of the attempts were effective enough to showcase here.

I can achieve the intended behavior at level 0, but selecting any child results in the following error:

2024-11-05 23:34:14 [Error] SubModelSelectedItem binding referenced binding "" but no binding was found with that name
2024-11-05 23:34:14 [Error] ["main"] Get FAILED: Binding "Some(SelectedNode)" could not be constructed

Help needed: Please feel free to add/replace/implement/adjust the code to complete the ItemsControl (and corresponding App + ViewModels files), which I believe represents the intended functionality:

        <ScrollViewer
            Grid.Column="1"
            Margin="10">
                <ItemsControl
                    d:ItemsSource="{Binding ?}">
                    <ItemsControl.ItemTemplate>
                        <HierarchicalDataTemplate
                            d:DataType="{x:Type vm:?}"
                            d:ItemsSource="{Binding ?}">
                            <GroupBox
                                Margin="5"
                                Header="Level">
                                <ListBox
                                    d:ItemsSource="{Binding ?}"
                                    d:SelectedItem="{Binding ?}"
                                    DisplayMemberPath="Name" />
                            </GroupBox>
                        </HierarchicalDataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
        </ScrollViewer>

Community: @awaynemd I know you’ve asked many similar questions before—have you ever found a fully satisfying way to use subModelSelectedItem recursively?

@xperiandri Do you happen to have any helpers in elmish.UNO that could assist with tasks like this?

@TysonMN (if you have time 😉), I believe I’ve read everything you’ve written about these "issues" in the past concerning recursive uses of subModelSelectedItem, but I’m still quite confused. Is there an idiomatic way to use them within an ItemsControl as in my example?

@marner2 In your static bindings API revision, is it part of your plan to make a simple helper similar to subModelSelectedItem, but with static typing and easy optional recursion?

Thank you all so much—I’ve been trying different approaches for the past two weeks, all ending in humbling failures😓

xperiandri commented 1 week ago

Nope. I've just implemented a similar logic using a BreadcrumbBar backed by submodelSeq And that logic looks extremely ugly. I'm going to rewrite it one day. I see the best option is (anonymous types used for readability)

type Model = {
  Items : Item0 list
  SelectionModel : {|
    SelectedItem : Item0
    Items: Item1 list
    SelectionModel : {|
      SelectedItem : Item1
      Items: Item2 list
      SelectionModel : Item2 voption
    |} voption
  |} voption
}
with
  interface IModel with
  interface IReadonlyList<IModel> with
  interface IEnumerable<IModel> with
    member model.GetEnumerator() =
      (seq {
          yield model :> IModel
          yield! model.SelectionModel |> ValueOption.toList
          yield! model.SelectionModel |> ValueOption.map _.SelectionModel |> ValueOption.toList
      }).GetEnumerator()
YkTru commented 6 days ago

I feel you..

Ramblings: as I barely understand what is the main issue with recursive subModelSelectedItem, my intuition is that all level should have a specific, unique subModelSelectedItem binding, as I did in the hardcoded example:

    let selectedLevel0Binding =
        Binding.subModelSelectedItem (
            "Level0Items_VM",
            (fun m -> m.SelectedLevel0),
            (fun selectedId model -> SelectLevel0 selectedId)
        )

    let selectedLevel1Binding =
        Binding.subModelSelectedItem (
            "Level1Items_VM",
            (fun m -> m.SelectedLevel1),
            (fun selectedId model -> SelectLevel1 selectedId)
        )

    let selectedLevel2Binding =
        Binding.subModelSelectedItem (
            "Level2Items_VM",
            (fun m -> m.SelectedLevel2),
            (fun selectedId model -> SelectLevel2 selectedId)
        )

Correspondingly, all items should have a "Selected option" (and/or "SelectedLevel"?) property (I'm not sure about this though..)


(I realize this might seem a bit out there, but I’m running out of options, and I lack proper understanding of ElmishWPF source code)

Proposition: To create a dynamic tree that allows adding and removing siblings or children, could it be possible to assign each newly added child at any level a unique subModelSelectedItem binding + SelectedLevel prop? This is what I had to do in the hardcoded example, so I’m wondering if it would be feasible and performant enough in practice?

Issues: From my understanding of Elmish.WPF's limitations, it seems we can't create bindings dynamically. However, is there truly no way to achieve similar functionality? Is it currently impossible to build a tree where selecting any item at any level would update properties in a synchronized property editor? ..I'm quite worried right now..

( @marner2 if you have time) Am I speaking non-sense?

xperiandri commented 6 days ago

Submodels can be created dynamically in submodels seq

xperiandri commented 6 days ago
module Bindings =

    let private viewModel = Unchecked.defaultof<SelectLocationViewModel>

    let pathBinding =

        let createViewModel (args : ViewModelArgs<obj, obj>) : IViewModel<obj, obj> =
            let modelType = args.InitialModel.GetType ()
            if modelType = typeof<Floor.Model> then
                Floor.FloorItemViewModel args
            elif modelType = typeof<Area.Model> then
                Area.AreaItemViewModel args
            elif modelType = typeof<Room.Model> then
                Room.RoomItemViewModel args
            else
                failwithf "Unknown model type: %A" modelType

        let mapVmMsg (index, msg : obj) : Msg =
            match msg with
            | :? Floor.Msg as m -> FloorMsg (index, m)
            | :? Area.Msg as m -> AreaMsg (index, m)
            | :? Room.Msg as m -> RoomMsg (index, m)
            | _ -> failwithf "Unknown message type: %A" msg

        BindingT.subModelSeq createViewModel (nameof viewModel.Path)
        |> Binding.mapModel (fun m -> m.Path)
        |> Binding.addLazy (fun m1 m2 -> m1.Path = m2.Path)
        |> Binding.mapMsg (fun (i, msg) -> mapVmMsg (i, msg))

type SelectLocationViewModel (args) =
    inherit ViewModelBase<Model, Msg> (args)

    member _.Path = base.Get (Bindings.pathBinding)
YkTru commented 5 days ago

I think I understand the bulk of it;


Regarding my specific case

build a tree where selecting any item at any level would update properties in a synchronized property editor

If I'm (hopefully) mistaken, I believe a sample for this scenario would be incredibly valuable, as tree structures combined with property editors are quite common in complex desktop applications. Honestly, I really hope I've simply missed or misunderstood the solutions suggested in the various discussions.. otherwise I'll have to give up my whole project..

xperiandri commented 5 days ago
  • one question though: how can you access the type SelectLocationViewModel in let private viewModel = Unchecked.defaultof<SelectLocationViewModel> since its defined after?

It is used for nameof only

xperiandri commented 5 days ago
  • Everytime I tried to access args, I couldn't get anywhere; • ie I get no Intellisense (except GetType & ToString; I tried open Elmish.WPF.ViewModelArgs/ViewModel to no avail • and I get errors such as: The type 'ViewModelArgs<_,_>' does not define the field, constructor or member 'InitialModel'

I've added such property in Elmish.Uno

YkTru commented 4 days ago

I can barely think straight—I’ve hardly slept in three days trying to figure this out I'm exhausted; If I can’t resolve this issue, my whole project is doomed.. And there’s no way I’m going back to C#+MVVM+Prism and all the nulls/classes/interfaces/services/tests/files explosion madness. It's a shame MS refuse to add proper F#+WPF/MAUI implementation.. what a waste.


Anyway… would a less-than-ideal setup like this actually work? I can’t seem to make it happen, and with my lack of sleep and experience here, I’m struggling to judge it clearly.

AppY.fs: (all compiles, I add the 'Y' suffix to avoid collisions):

type Model =
        { Items: Parent list
          SelectedItem: Guid option
        }

        // Msg
        | UpdateSelectedItem of Guid option

        // update
        | UpdateSelectedItem itemId ->
            { model with
                SelectedItem = itemId
                Log = sprintf "Selected Item: %A" itemId },
            Cmd.none

    // function to call from C#.. if possible/relevant
    let dispatchUpdateSelectedItem (dispatch: Msg -> unit) itemId = 
        dispatch (UpdateSelectedItem itemId)

MainWindow.cs: And now the terrible, pathetic part (I can't find what to put in dispatch):

using Elmish;
using Elmish.WPF;

namespace TreeView_SelectedItem_Behaviors.WPF;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void TreeView_OnSelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
    {

        Action<AppY.Msg> dispatch = ???

        if (e.NewValue is Parent_VM p)
        {
            AppY.dispatchUpdateSelectedItem(dispatch, p.Id);
        }
    }
}

.. I really can’t stand interop…

Well, I really hope someone can eventually help with this.. thanks.

xperiandri commented 4 days ago

Why don't you use submodelSeq Get initial model via reflection for now

YkTru commented 4 days ago

@xperiandri Would you be able (if you have time) to share a sample please, ideally as a GitHub repository or a zip file so I can better analyze, with just the essentials (Program file + XAML) to demonstrate exactly your proposed approach? (I'm "conceptually" confused by the recursive multi-level item selection part + the dynamic/static issues between ElmishWPF and WPF)

Or even better (if it's easier), you could submit a PR to the repo I shared earlier, focusing only on adding the specific bindings and XAML modifications (assuming my coding style is clear)?

This would be for sure very helpful. Thank you

xperiandri commented 3 days ago

@YkTru see https://github.com/xperiandri/Elmish.Uno/tree/submodel-selected-item-cascading SubModelSelectedItem.Cascading.fs

I have not finished UI, only logic

xperiandri commented 3 days ago

@marner2 I've rebased my code on top of your latest code. So no my changes are more easily pickable.

Pay attention to Fixed wrong initial binding value in static view models base class commit

YkTru commented 2 days ago

@xperiandri Great, thanks! I'll take a closer look at this later in the week when I have time and work on adapting my sample with it.

Questions: (These are just "at first glance" questions/uneasiness)

Is there any risk associated with using reflection to get the InitialModel? Could someone potentially access and manipulate critical fields in the Model this way?

xperiandri commented 2 days ago

I don't care if it is OO or FP approach, whichever solves the problem better. Model property and IEnumerable implementation can actually be omitted and a function transforming into a list can be used. And IModel can be omitted too, you will just expose an obj list So that sample can be simplified

YkTru commented 2 days ago

I don't care if it is OO or FP approach, whichever solves the problem better. Model property and IEnumerable implementation can actually be omitted and a function transforming into a list can be used. And IModel can be omitted too, you will just expose an obj list So that sample can be simplified

• I totally agree: I am just curious why you opted for the OO approach in that sample and what advantages you see in it compared to the FP approach, which, all else being equal, seems easier to read and more concise to me. I’m not very familiar with F# OO, so I may not fully appreciate its benefits (as I mostly learned from Wlaschin, who rarely uses OO in his code snippets)

• Regarding using reflection to obtain the 'Initial Model,' sorry to insist but do you think it's safe enough in this specific case? I've often been advised to avoid reflection except for debugging or plugin-based extensibility, but just like with OO I'm open to revise my beliefs/impressions as well

xperiandri commented 2 days ago

Yes, it is safe @marner2 maybe add that initial model property in WPF too?

TysonMN commented 8 hours ago

I can barely think straight—I’ve hardly slept in three days trying to figure this out I'm exhausted;

I don't recommend trying to solve a problem like this without sleep. In fact, I love trying to solve a problem like this while in bed trying to fall asleep.

Have you solved this problem with a traditional WPF (i.e. MVVM C#) application? If so, can you share a minimal working example of that?

YkTru commented 2 hours ago

@TysonMN Here I made this C# implementation this morning (I use the excellent MVVMGen library for cleaner VMs):

https://github.com/YkTru/Cascading-ListBoxes---SubModelSelectedItem/tree/C%23_MVVM

It's a quick and rough draft, so my naming conventions and usage of MVVMGen might not be perfectly consistent throughout.

However, everything is functioning exactly as I intended, ie:

Goal: I’m aiming for an ItemsControl that can dynamically create ListBoxes based on selected items or added children, supporting an n-level depth.


Note: I still depends on the SelectedItem prop of the ListBox element in the template: SelectedItem="{Binding SelectedNode, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"

I therefore still haven't figured out how to achieve the same behaviors in Elmish.WPF (particularly when using subModelItemSelected).

One thing I’m certain of now, though, is that I must use a Level_VM in the ElmishWPF version.


F# Version update: I don’t have time today to work on a corresponding F# version, but if you’d like to share any insights or code samples to guide me as for the bindings part (you can refer to my master branch's Models/VM/Xaml), it would surely be helpful.