czwartek, 30 marca 2023

Wzorzec kreacyjny - Builder

Wzorzec budowniczy (ang. Builder) to jeden z wielu wzorców kreacyjnych. Proces tworzenia obiektów może być czasochłonny i skomplikowany, szczególnie w przypadku, gdy wymaga przestrzegania złożonych zasad. Wzorzec pomaga w tworzeniu obiektów w sposób elastyczny i skalowalny. Jego głównym celem jest umożliwienie kreowania złożonych obiektów krok po kroku, bez konieczności tworzenia wielu konstruktorów.

Powinniśmy zacząć od lekkiego, popularnego przykładu, takiego jak "budowa domu", jednak większą przyjemność dostarczy "życiowy" kod. Poniższy fragment został zaczerpnięty ze strony producenta kontrolek Devexpress (How to create a ribboncontrol in code). Metoda InitRibbon ilustruje inicjalizację kontroli RibbonControl, stanowi częścią klasy dziedziczącej po Form.

public partial class MainForm : Form
{
    private ImageCollection _imageCollection;

    public MainForm()
    {
        InitializeComponent();

        InitImageCollection();
        InitRibbon();
    }

    private void InitImageCollection()
    {
        _imageCollection = new ImageCollection();
        // _imageCollection.AddImage(...);
    }

    private void InitRibbon()
    {
        RibbonControl ribbon = new RibbonControl();

        // Assign the image collection that will provide images for bar items.
        ribbon.Images = _imageCollection;

        // Create a Ribbon page.
        RibbonPage page1 = new RibbonPage("Home");
        // Create a Ribbon page group.
        RibbonPageGroup group1 = new RibbonPageGroup("File");
        // Create another Ribbon page group.
        RibbonPageGroup group2 = new RibbonPageGroup("Edit");

        // Create a button item using the CreateButton method.
        // The created item is automatically added to the item collection of the RibbonControl.
        BarButtonItem itemOpen = ribbon.Items.CreateButton("Open");
        itemOpen.ImageIndex = 0;
        // Ensures correct runtime layout (de)serialization.
        itemOpen.Id = ribbon.Manager.GetNewItemId(); 
        itemOpen.ItemClick += new ItemClickEventHandler(itemOpen_ItemClick);

        // Create a button item using its constructor.
        // The constructor automatically adds the created item to the RibbonControl's collection.
        BarButtonItem itemClose = new BarButtonItem(ribbon.Manager, "Close");
        itemClose.ImageIndex = 1;
        // Ensures correct runtime layout (de)serialization.
        itemClose.Id = ribbon.Manager.GetNewItemId(); 
        itemClose.ItemClick += new ItemClickEventHandler(itemClose_ItemClick);

        // Create a button item using the default constructor.
        BarButtonItem itemPrint = new BarButtonItem();
        // Manually add the created item to the item collection of the RibbonControl.
        ribbon.Items.Add(itemPrint);
        itemPrint.Caption = "Print";
        itemPrint.ImageIndex = 2;
        // Ensures correct runtime layout (de)serialization.
        itemPrint.Id = ribbon.Manager.GetNewItemId(); 
        itemPrint.ItemClick += new ItemClickEventHandler(itemPrint_ItemClick);

        // Create a button item using the default constructor.
        BarButtonItem itemSave = new BarButtonItem();
        // Manually add the created item to the item collection of the RibbonControl.
        ribbon.Items.Add(itemSave);
        itemSave.Caption = "Save";
        itemSave.ImageIndex = 3;
        // Ensures correct runtime layout (de)serialization.
        itemSave.Id = ribbon.Manager.GetNewItemId(); 
        itemSave.ItemClick += new ItemClickEventHandler(itemSave_ItemClick);

        // Add the created items to the group using the AddRange method. 
        // This method will create bar item links for the items and then add the links to the group.
        group1.ItemLinks.AddRange(new BarItem[] { itemOpen, itemClose, itemPrint });
        // Add the Open bar item to the second group.
        group2.ItemLinks.Add(itemSave);
        // Add the created groups to the page.
        page1.Groups.Add(group1);
        page1.Groups.Add(group2);
        // Add the page to the RibbonControl.
        ribbon.Pages.Add(page1);

        this.Controls.Add(ribbon);
    }

    //...
}

Przykład dostarczony przez Devexpress ilustruje proces tworzenia komponentu RibbonControl oraz jak ważne jest przestrzeganie przyjętych schematów. Poniżej zamieszczam końcowy efekt.

RibbonControl

Zastosowanie wzorca umożliwi wydzielenie kodu związanego z procesem tworzenia komponentu RibbonControl. Klasa RibbonControlBuilder reprezentuje najprostszą formę budowniczego, który posiada wydzielone metody odpowiedzialne za dodanie poszczególnych elementów. Dodatkowo pojawiła się metoda AssignImageCollection, umożliwiająca przypisanie kolekcji ikon. Zakładamy, że nie każda tworzona kontrolka RibbonControl musi korzystać z ImageCollection. Metoda Build zwraca gotowy komponent "wstęgi" (ang. Ribbon).

public class RibbonControlBuilder
{
    private RibbonControl _ribbon;

    public RibbonControlBuilder()
    {
        _ribbon = new RibbonControl();
    }

    public void AssignImageCollection(ImageCollection imageCollection)
    {
        _ribbon.Images = imageCollection;
    }

    public void AddPage(string caption)
    {
        var page = new RibbonPage(caption) 
        {  
            Name = caption
        };

        _ribbon.Pages.Add(page);
    }

    public void AddGroup(string page, string caption)
    {
        var group = new RibbonPageGroup(caption) 
        { 
            Name = caption 
        };

        _ribbon.Pages.GetPageByText(page)
            .Groups.Add(group);
    }

    public void AddItem(string group, string caption, int imageIndex, 
        ItemClickEventHandler itemClick)
    {
        var item = new BarButtonItem()
        { 
            Name = caption, 
            Caption = caption,
            ImageIndex = imageIndex,
            // Ensures correct runtime layout (de)serialization.
            Id = _ribbon.Manager.GetNewItemId(), 
        };

        item.ItemClick += itemClick;

        _ribbon.Items.Add(item);
        _ribbon.GetGroupByName(group)
            .ItemLinks.Add(item);
    }

    public RibbonControl Build()
    {
        return _ribbon;
    }
}

W przeciwieństwie do oryginalnej metody InitRibbon, która jest częścią MainForm, budowniczy może zostać użyty wszędzie tam, gdzie jest potrzebna kontrolka RibbonControl. Dodatkowo zwiększamy czytelność kodu, pozwala to na usunięcie części komentarzy. Przygotowanie testów, które zwiększają bezpieczeństwo używania komponentów dostarczanych przez firmy zewnętrzne, nie stanowi wyzwania. Wadą zaprezentowanego budowniczego jest konieczność podawania nazw elementów nadrzędnych. Przykładem jest metoda AddItem, która oprócz caption wymaga przekazania nazwy grupy. Może to prowadzić do pomyłek, gdy nazwy są niepoprawnie przekazane lub zmienione.

Poniżej zmodyfikowany kod metody InitRibbon, korzystającej ze wzorca.

public partial class MainForm : Form
{
    private ImageCollection _imageCollection;

    public MainForm()
    {
        InitializeComponent();

        InitImageCollection();
        InitRibbon();
    }

    private void InitImageCollection()
    {
        //...
    }

    private void InitRibbon()
    {
        var builder = new RibbonControlBuilder();
        builder.AssignImageCollection(_imageCollection);
        builder.AddPage("Home");
        builder.AddGroup("Home", "File");
        builder.AddItem("File", "Open", 0, itemOpen_ItemClick);
        builder.AddItem("File", "Close", 1, itemClose_ItemClick);
        builder.AddItem("File", "Print", 2, itemPrint_ItemClick);
        builder.AddGroup("Home", "Edit");
        builder.AddItem("Edit", "Save", 3, itemOpen_ItemClick);
        var ribbonControl = builder.Build();

        this.Controls.Add(ribbonControl);
    }

    //...
}

Ciekawym rozwiązaniem jest zastosowanie tzw. "płynnego" budowniczego. W tym podejściu każda metoda zwraca instancję obiektu, co pozwala na bezpośrednie wykonywanie kolejnych metod po sobie. Dodatkowo, poprzez ukrycie konstruktora klasy RibbonControl, możemy wskazać użytkownikowi komponentu preferowany sposób inicjalizacji. W tym celu klasa RibbonControl musi zostać wyposażona w statyczną metodę Create, która zwróci instancję budowniczego. Jednak w tym przypadku, klasa RibbonControl dostarczana jest przez firmę zewnętrzną, przez co nie mamy możliwości bezpośredniej modyfikacji. W takim przypadku możemy dziedziczyć po klasie RibbonControl i rozszerzyć ją o metodę Create, jak w przykładzie poniżej. Częściowym rozwiązaniem byłoby umieszczenie metody Create jako Extension Method dla RibbonControl.

public class MyRibbonControl : RibbonControl
{
    private MyRibbonControl() 
        : base()
    {

    }

    public static RibbonControlBuilder Create()
    {
        return new RibbonControlBuilder();
    }
}

public class RibbonControlBuilder
{
    private RibbonControl _ribbon;

    public RibbonControlBuilder()
    {
        _ribbon = new RibbonControl();
    }

    public RibbonControlBuilder AssignImageCollection(ImageCollection imageCollection)
    {
        //...
        return this;
    }

    public RibbonControlBuilder AddPage(string caption)
    {
        //...
        return this;
    }

    public RibbonControlBuilder AddGroup(string page, string caption)
    {
        //...
        return this;
    }

    public RibbonControlBuilder AddItem(string group, string caption, int imageIndex,
        ItemClickEventHandler itemClick)
    {
        //...
        return this;
    }

    public RibbonControl Build()
    {
        return _ribbon;
    }

    public static implicit operator RibbonControl(RibbonControlBuilder builder)
        => builder.Build();
}

Przykład powyżej stosuje operator umożliwiający wykonanie niejawnej konwersji pomiędzy RibbonControlBuilder a RibbonControl. Jego działanie ogranicza się do wywołania metody Build, dzięki czemu nie musimy jej jawnie wykonywać. Kod poniżej został "sztucznie" sformatowany, co ma na celu zwiększenie czytelności wykonywanych operacji. Pamiętaj, o rozsądnym korzystaniu z operatorów konwersji.

private void InitRibbon()
{
    this.Controls.Add(MyRibbonControl.Create()
        .AssignImageCollection(_imageCollection)
        .AddPage("Home")
            .AddGroup("Home", "File")
                .AddItem("File", "Open", 0, itemOpen_ItemClick)
                .AddItem("File", "Close", 1, itemClose_ItemClick)
                .AddItem("File", "Print", 2, itemPrint_ItemClick)
            .AddGroup("Home", "Edit")
        .AddItem("Edit", "Save", 3, itemSave_ItemClick));
}

Użycie wcześniej przedstawionych budowniczych wymaga przekazywania wszystkich parametrów do metod, takich jak AddItem. Oczywiście możemy stworzyć przeciążenia metod, aby umożliwić użycie najbardziej pasującej do nas metody. Jednakże, czy nie możemy rozwiązać tego problemu w inny sposób? Z pomocą przychodzi złożony budowniczy.

Wprowadźmy kilka zmian w klasie RibbonControlBuilder, takich jak udostępnienie pola _ribbon typu RibbonControl dla klas potomnych, dodanie drugiego konstruktora (jeden z domyślną inicjalizacją, drugi wykorzystywany przez klasy potomne), przeniesienie metod AddGroup i AddItem do klas potomnych oraz modyfikacja typu zwracanego przez metodę AddPage (RibbonPageBuilder). Przedstawione zmiany umożliwiają implementację złożonego budowniczego, co może ułatwić i przyspieszyć proces tworzenia.

public class RibbonControlBuilder
{
    protected RibbonControl _ribbon;

    public RibbonControlBuilder() 
        : this(new RibbonControl())
    {
    }

    protected RibbonControlBuilder(RibbonControl ribbon)
    {
        _ribbon = ribbon;
    }

    public RibbonControlBuilder AssignImageCollection(ImageCollection imageCollection)
    {
        _ribbon.Images = imageCollection;
        return this;
    }

    public RibbonPageBuilder AddPage(string text) 
        => new RibbonPageBuilder(_ribbon, text);


    public RibbonControl Build()
    {
        return _ribbon;
    }

    public static implicit operator RibbonControl(RibbonControlBuilder builder)
        => builder.Build();
}

Klasa RibbonPageBuilder podobnie jak RibbonControlBuilder udostępnia dwa konstruktory (pierwszy używany przez metodę AddPage, drugi przeznaczony dla klas potomnych). Klasa implementuje metody Visible oraz Image umożliwiające ustawienie obiektu _page (RibbonPage). Obie metody zostały określone jako wirtualne, przez co możliwe będzie ich przeciążenie w klasach potomnych (zarówno klasa RibbonPage jak i RibbonPageGroup umożliwiają określenie widoczności). Innym rozwiązaniem byłoby poprzedzenie metod prefiksem wskazującym na obsługiwany typ np. PageVisible.

public class RibbonPageBuilder : RibbonControlBuilder
{
    protected RibbonPage _page;

    protected RibbonPageBuilder(RibbonControl ribbon, RibbonPage page)
        : base(ribbon)
    {
        _page = page;
    }

    internal RibbonPageBuilder(RibbonControl ribbon, string text)
        : base(ribbon)
    {
        _page = new RibbonPage()
        {
            Name = text
        };

        _ribbon.Pages.Add(_page);
    }

    public virtual RibbonPageBuilder Visible(bool visible)
    {
        _page.Visible = visible;
        return this;
    }

    public virtual RibbonPageBuilder Image(Image image)
    {
        _page.ImageOptions.Image = image;
        return this;
    }

    public RibbonPageGroupBuilder AddGroup(string text) 
        => new RibbonPageGroupBuilder(_ribbon, _page, text);
}

Kolejna klasa RibbonPageGroupBuilder jest niemal "powieleniem" RibbonPageBuilder. Dwa konstruktory jeden wykorzystywany przez metodę AddGroup, drugi przeznaczony dla klas potomnych. Właściwości umożliwiające sterowaniem _group (RibbonPageGroup), oraz metod AddItem odpowiedzialny za tworzenie kolejnego typu potomnego.

public class RibbonPageGroupBuilder : RibbonPageBuilder
{
    protected RibbonPageGroup _group;

    protected RibbonPageGroupBuilder(RibbonControl ribbon, RibbonPage page, RibbonPageGroup group)
        : base(ribbon, page)
    {
        _group = group;
    }

    internal RibbonPageGroupBuilder(RibbonControl ribbon, RibbonPage page, string text)
        : base(ribbon, page)
    {
        _group = new RibbonPageGroup(text)
        {
            Name = text,
            SuperTip = new SuperToolTip()
        };

        _page.Groups.Add(_group);
    }

    public override RibbonPageGroupBuilder Visible(bool visible)
    {
        _group.Visible = visible;
        return this;
    }

    public RibbonPageGroupBuilder CaptionButtonVisible(DefaultBoolean visible)
    {
        _group.CaptionButtonVisible = visible;
        return this;
    }

    public virtual RibbonPageGroupBuilder SuperTooltip(string text)
    {
        _group.SuperTip.Items.Add(text);
        return this;
    }

    public BarButtonItemBuilder AddItem(string text) => AddItem(text, null);

    public BarButtonItemBuilder AddItem(string text, ItemClickEventHandler itemClick) 
        => new BarButtonItemBuilder(_ribbon, _page, _group, text, itemClick);
}

Ostatnim elementem układanki jest klasa BarButtonItemBuilder. Dostępny jest tylko jeden konstruktor wywoływany poprzez metodę AddItem.

public class BarButtonItemBuilder : RibbonPageGroupBuilder
{
    protected BarButtonItem _item;

    internal BarButtonItemBuilder(RibbonControl ribbon, RibbonPage page, RibbonPageGroup group, 
        string caption, ItemClickEventHandler itemClick)
        : base(ribbon, page, group)
    {
        _item = new BarButtonItem()
        {
            Name = caption,
            Caption = caption,
            // Ensures correct runtime layout (de)serialization.
            Id = _ribbon.Manager.GetNewItemId(),
            SuperTip = new SuperToolTip(),
        };

        _item.ItemClick += itemClick;

        _group.ItemLinks.Add(_item);
        _ribbon.Items.Add(_item);
    }

    public BarButtonItemBuilder Visibility(BarItemVisibility visibility)
    {
        _item.Visibility = visibility;
        return this;
    }

    public override BarButtonItemBuilder Image(Image image)
    {
        _item.ImageOptions.Image = image;
        return this;
    }

    public BarButtonItemBuilder ItemClick(ItemClickEventHandler itemClick)
    {
        _item.ItemClick += itemClick;
        return this;
    }

    public override BarButtonItemBuilder SuperTooltip(string text)
    {
        _item.SuperTip.Items.Add(text);
        return this;
    }
}

Pozostaje zaprezentować przygotowanego rozwiązania poprzez dostosowanie metody InitRibbon.

private void InitRibbon()
{
    this.Controls.Add(MyRibbonControl.Create()
        .AssignImageCollection(_imageCollection)
        .AddPage("Home")
            .Image(_imageCollection.Images["home"])
            .AddGroup("File")
                .SuperTooltip("Advanced options")
                .AddItem("Open")
                    .Image(_imageCollection.Images["open"])
                    .ItemClick(itemOpen_ItemClick)
                .AddItem("Close")
                    .Image(_imageCollection.Images["close"])
                    .ItemClick(itemClose_ItemClick)
                .AddItem("Print")
                    .Image(_imageCollection.Images["print"])
                    .ItemClick(itemPrint_ItemClick)
            .AddGroup("Edit")
                .CaptionButtonVisible(DefaultBoolean.True)
                .AddItem("Save")
                    .Image(_imageCollection.Images["editname"]));
}

Wprowadzone zmiany umożliwiły pozbycie się uciążliwego wskazywania elementu nadrzędnego poprzez podanie jego nazwy. Należy zwrócić uwagę na "obecność" metod z klas bazowych w klasach potomnych. Przykładem jest metoda AssignImageCollection, dostępna w BarButtonItemBuilder. Jest to wada, wynikająca ze sposobu tworzenia hierarchii. Jednym ze sposobów na poprawę i zwiększenie czytelności jest przekazanie delegata typu Action<T>, gdzie T jest typem tworzonego obiektu. Dla przykładu dla metody AddPage będzie to Action<RibbonPageBuilder>, natomiast dla AddGroup Action<RibbonPageGroupBuilder>. W ten sposób, kiedy użytkownik wywołuje metodę AddPage, może w łatwy sposób dodać obsługą RibbonPageBuilder. Poniżej zamieszczono modyfikację kodu klasy RibbonControlBuilder oraz InitRibbon.

public abstract class RibbonControlAbstractBuilder
{
    protected RibbonControl _ribbon;

    protected RibbonControlAbstractBuilder(RibbonControl ribbon)
    {
        _ribbon = ribbon;
    }
}

public class RibbonControlBuilder : RibbonControlAbstractBuilder
{
    public RibbonControlBuilder() 
        : base(new RibbonControl())
    {
    }

    public RibbonControlBuilder AssignImageCollection(ImageCollection imageCollection)
    {
        _ribbon.Images = imageCollection;
        return this;
    }

    public RibbonControlBuilder AddPage(string text, Action<RibbonPageBuilder> action)
    {
        var page = new RibbonPageBuilder(_ribbon, text);
        action?.Invoke(page);
        return this;
    }
         
    public RibbonControl Build()
    {
        return _ribbon;
    }

    public static implicit operator RibbonControl(RibbonControlBuilder builder)
        => builder.Build();
}

public class RibbonPageBuilder : RibbonControlAbstractBuilder
{
    protected RibbonPage _page;

    internal RibbonPageBuilder(RibbonControl ribbon, string text)
        : base(ribbon)
    {
        _page = new RibbonPage()
        {
            Name = text
        };

        _ribbon.Pages.Add(_page);
    }

    public RibbonPageBuilder Visible(bool visible)
    {
        _page.Visible = visible;
        return this;
    }

    public RibbonPageBuilder Image(Image image)
    {
        _page.ImageOptions.Image = image;
        return this;
    }

    public RibbonPageBuilder AddGroup(string text, Action<RibbonPageGroupBuilder> action)
    {
        var group = new RibbonPageGroupBuilder(_ribbon, _page, text);
        action?.Invoke(group);
        return this;
    }
}

public class RibbonPageGroupBuilder : RibbonControlAbstractBuilder
{
    protected RibbonPageGroup _group;

    internal RibbonPageGroupBuilder(RibbonControl ribbon, RibbonPage page, string text)
        : base(ribbon)
    {
        _group = new RibbonPageGroup(text)
        {
            Name = text,
            SuperTip = new SuperToolTip()
        };

        page.Groups.Add(_group);
    }

    public RibbonPageGroupBuilder Visible(bool visible)
    {
        _group.Visible = visible;
        return this;
    }

    public RibbonPageGroupBuilder CaptionButtonVisible(DefaultBoolean visible)
    {
        _group.CaptionButtonVisible = visible;
        return this;
    }

    public RibbonPageGroupBuilder SuperTooltip(string text)
    {
        _group.SuperTip.Items.Add(text);
        return this;
    }

    public RibbonPageGroupBuilder AddItem(string text, ItemClickEventHandler itemClick, 
        Action<BarButtonItemBuilder> action)
    {
        var item = new BarButtonItemBuilder(_ribbon, _group, text, itemClick);
        action?.Invoke(item);
        return this;
    }
}

public class BarButtonItemBuilder : RibbonControlAbstractBuilder
{
    protected BarButtonItem _item;

    internal BarButtonItemBuilder(RibbonControl ribbon, RibbonPageGroup group, string caption, 
        ItemClickEventHandler itemClick)
        : base(ribbon)
    {
        _item = new BarButtonItem()
        {
            Name = caption,
            Caption = caption,
            // Ensures correct runtime layout (de)serialization.
            Id = _ribbon.Manager.GetNewItemId(),
            SuperTip = new SuperToolTip(),
        };

        _item.ItemClick += itemClick;

        group.ItemLinks.Add(_item);
        _ribbon.Items.Add(_item);
    }

    public BarButtonItemBuilder Visibility(BarItemVisibility visibility)
    {
        _item.Visibility = visibility;
        return this;
    }

    public BarButtonItemBuilder Image(Image image)
    {
        _item.ImageOptions.Image = image;
        return this;
    }

    public BarButtonItemBuilder ItemClick(ItemClickEventHandler itemClick)
    {
        _item.ItemClick += itemClick;
        return this;
    }

    public BarButtonItemBuilder SuperTooltip(string text)
    {
        _item.SuperTip.Items.Add(text);
        return this;
    }
}
private void InitRibbon()
{
    this.Controls.Add(MyRibbonControl.Create()
        .AssignImageCollection(_imageCollection)
        .AddPage("Home", page =>
        {
            page.Image(_imageCollection.Images["home"])
                .AddGroup("File", group =>
                {
                    group.SuperTooltip("Advanced options")
                        .AddItem("Open", itemOpen_ItemClick, item => 
                        {
                            item.Image(_imageCollection.Images["open"]);
                        })
                        .AddItem("Close", itemClose_ItemClick, item =>
                        {
                            item.Image(_imageCollection.Images["close"]);
                        })
                        .AddItem("Print", itemPrint_ItemClick, item =>
                        {
                            item.Image(_imageCollection.Images["print"]);
                        });
                })
                .AddGroup("Edit", group =>
                {
                    group.CaptionButtonVisible(DefaultBoolean.True)
                        .AddItem("Save", itemSave_ItemClick, item =>
                        {
                            item.Image(_imageCollection.Images["editname"]);
                        });
                });
        }));
}

Celem wzorca Budowniczy jest umożliwienie tworzenia złożonych komponentów. Budowniczowie mogą udostępniać płynny interfejs, który pozwala na tworzenie łańcucha wywołań. Aby wymusić użycie budowniczego, można ukryć konstruktor i utworzyć statyczną metodę Create. Możliwe jest niejawne wywołanie metody Build przez dodanie operatora konwersji. Warto zauważyć, że pojedynczy interfejs budowniczego może udostępniać inne pomocnicze budownicze. Możemy wymusić podanie budowniczego w formie parametru metody. Dziedziczenie płynnego interfejsu możemy zrealizować poprzez zastosowanie rekurencyjnego typu generycznego (ten "szalony" przykład zostanie pominięty w notatce).

Wzorzec budowniczy może być stosowany w sytuacjach, gdy:

  • konstrukcja komponentu wymaga skomplikowanego procesu,
  • chcemy oddzielić proces konstrukcji od samego komponentu,
  • chcemy uzyskać różne warianty komponentu, które mają wspólny proces konstrukcji,
  • chcemy uprościć proces tworzenia komponentu poprzez oddzielenie procesu konstrukcji od sposobu reprezentacji.

Link do projektu RibbonBuilderDemo.zip

Troska Robert