Dynamiczne Item Context Menu (ECB)

Wstęp

Swojego czasu poświęciłem trochę czasu na analizę sposobów tworzenia elementów menu czy to Site Action czy też toolbar buttons jak i ECB – Edit Control Block.

Elementy ECB to te elementy menu, które pokazują się na widoku listy jako context menu od elementu na liście. Czyli takie akcje jak „Edit Item” czy „Display Item” do nich należą. Tak jak pisałem wcześniej istnieje kilka (a dokładnie dwa) sposobów tworzenia takich elementów. Pierwszy jest poprzez feature i XML tag CustomAction, drugi zaś poprzez oprogramowanie funkcji JS Custom_AddListMenuItems oraz Custom_AddDocLibMenuItems.

Oba sposoby są przydatne kiedy chcemy stworzyć banalnie proste elementy menu, dostępne dla wszystkich elementów list lub dla konkretnego content type. Można jednak znaleźć kilka znaczących różnic pomiędzy nimi:

1)      Custom Action może zostać wgrany dla każdego typu elementu lub dla konkretnego content type, jednakże jego widoczność minimalnie ograniczona jest do Web;

JS może zostać  wgrany dla każdego typu elementu lub dla konkretnego content type, jego wykorzystanie przeważnie jest globalne – na wszystkie Web Application. Można ograniczać jego widoczność poprzez Web part Content Editor i Master Page; Bez posiadania SharePoint Designer jednak mamy tylko dwa wybory albo wszystkie Web App, albo tylko jedna lista;

2)      Custom Actions nie zezwalają na tworzenie grup elementów;

JS zezwala na tworzenie grup;

 

3)      Custom Actions nie mogą zawierać JavaScriptu na elemencie – uwaga: tego nie jestem w 100% pewny, nie testowałem ActionUrl w którym wkładałem kod JS!;

JS może zawierać jako akcje wywołanie JS – to testowałem i działa ślicznie! :)

4)      Custom Actions nie zezwalają na dynamiczne menu;

JS zezwala na dynamiczne menu.

Problem

No dobrze, to teraz jak sobie poradzić w sytuacji kiedy nasze menu, powinno pojawiać się wtedy i tylko wtedy kiedy wgrany element pochodzi od ContentType X oraz jego pole MyCustomStatus jest „Approved”?

Żeby zobrazować o co mi chodzi, poniżej są dwa screenshoty, które niezależnie od statusu faktury wyświetlają te same opcje w menu:

Osobiście częściowo uważałem iż albo się tego zrobić nie da, albo da się to zrobić ale grzebiąc w JavaScript, który wcale tak fajny do tego typu operacji nie jest. Jak opisywałem w poście o CustomActions metoda na kontrolkę ASCX nie działa i jest kompletnie olewana przez SharePoint. Więc jak zrobić dynamiczne, własne menu, które ma się pojawiać wtedy i tylko wtedy kiedy zostaną spełnione postawione przez nas wymagania?

Rozwiązanie

Na to pytanie postaram się zaraz odpowiedzieć na przykładach – dla ułatwienia pracy, nie będę wykorzystywał kodu kompilowanego do DLL, wykorzystamy dwa okna notatnika (choć dla wyraźności kodu, będę go kopiował z VS).

Zacznijmy od części z modelem obiektowym SharePoint. By móc działać na elemencie na danej liście potrzebujemy takich informacji jak Item ID i List ID. W tym celu musimy je jakoś w naszym kodzie pobrać – z powodu iż jesteśmy na widoku listy a nie na widoku elementu, SPContext.Current.ItemId by nam nie zadziałało. Dlatego zdecydowałem się przekazywać te parametry poprzez query string. No dobrze, otwieramy notepad i wpisujemy następujący kod:

<%@ Page Language="C#" %>

<%@ Import Namespace="Microsoft.SharePoint" %>

<%@ Import Namespace="Microsoft.SharePoint.WebControls" %>

<%@ Import Namespace="Microsoft.SharePoint.ApplicationPages" %>

<script runat="server">

   

    protected void Page_Load(object sender, EventArgs e)

    {

        // UWAGA: brak error handling, kod ten nie powinien w

        // ogole zwracac Exception!! wiec zwracam na to uwage

        SPWeb web = SPControl.GetContextWeb(this.Context);

        Guid listID = new Guid(this.Request.Params["ListID"]);

        int itemID = int.Parse(this.Request.Params["ItemID"]);

        SPListItem item = web.Lists[listID].Items.GetItemById(itemID);

 

        // Bedziemy zwracac XML wiec czyscimy to co trzeba

        HttpContext.Current.Response.ClearHeaders();

        HttpContext.Current.Response.ClearContent();

        this.Response.Cache.SetCacheability(HttpCacheability.NoCache);

        this.Response.AddHeader("Content-type", "text/xml");

 

        string cmdPattern = "<Response><IsApproved>{0}</IsApproved><IsRejected>{1}</IsRejected></Response>";

 

        this.Response.Write(@"<?xml version=""1.0"" encoding=""UTF-8"" ?>");

        this.Response.Write("<Root>");

 

        string status = item["_InvoiceStatus"] as string;

        bool isApproved = false;

        bool isRejected = false;

 

        // Sprawdzenie Is Approved;

        if(status == "Wystawiona")

        {

            isApproved = true;

        }

        else if(status == "Anulowana")

        {

            isRejected = true;

        }

 

        this.Response.Write(string.Format(cmdPattern, isApproved == true ? 1 : 0, isRejected == true ? 1 : 0));

 

        this.Response.Write("</Root>");

        this.Response.End();

    }

    

</script>

Plik zapisujemy w 12 HIVE/TEMPLATE/LAYOUTS – ja nazwałem go GetEcbVisibility.aspx. Jak zauważaliście w kodzie sprawdzam jedną wartość elementu i na jej podstawie tworze plik XML z odpowiednio ustawionymi parametrami. Ten plik XML będzie przez nas potem parsowany.

No dobrze, to mamy część code behind za sobą :) nawet nie trzeba jej kompilować! :)

To teraz pora zająć się menu i JavaScript. Zanim jednak przejdzemy do pisania JS, proponuje w następujący sposób umieścić go na stronie:

1)      Otwieramy widok listy

2)      Klikamy Site Actions | Edit Page

3)      Klikamy Add New Web Part i wybieramy Content Editor Web Part

4)      Następnie w opcjach Layout zaznaczamy Hiden

Od tej pory będziemy już operować na Source Editor od Conent Editor Web Part. Teraz w zależności od tego czy nasza lista jest zwykła listą czy biblioteką dokumentów nasza funkcja może nazywać się kolejno Custom_AddListMenuItems lub Custom_AddDocLibMenuItems. U mnie lista faktur jest zwykłą listą więc będę operował na Custom_AddListMenuItems.

Otwieramy source editor i prawie wklejamy identyczny kod (robię ograniczenie na mój content type):

<script language="JavaScript" type="text/javascript">

 

function Custom_AddListMenuItems(m, ctx)

{

    //AddInvoicePrintSubMenu(m, ctx);

    AddInvoiceActionSubMenu(m, ctx);     

 

      return false;

}

 

function AddInvoiceActionSubMenu(m, ctx)

{

    // Okreslenie Content Type do ktorego ma nastapic

    // przypisanie menu

    var L_ContentTypeID_Tex = "0x0100FA75F010DDCF491b949253DED971605D"

   

    // Wartosci tekstowe plus obrazki

      var L_MenuGroup_Text = "Faktura";

      var L_MenuItem_Approve_Text = "Zatwierdź (Wystaw)";

      var L_MenuItem_Reject_Text = "Anuluj";

      var L_MenuItem_Correct_Text = "F. Korygująca";

      var L_MenuItem_Approve_Image = ctx.imagesPath + "checkitems.gif";

      var L_MenuItem_Reject_Image = ctx.imagesPath + "DELITEM.GIF";

      var L_MenuItem_Correct_Image = ctx.imagesPath + "ConvertDocument.gif";

      var L_MenuItem_Approve_Action = "STSNavigate('" + ReplaceUrlTokens("{SiteUrl}/_layouts/DispInvoice.aspx?List={ListId}&amp;ID={ItemId}&amp;action=1", ctx) + "')";

      var L_MenuItem_Reject_Action = "STSNavigate('" + ReplaceUrlTokens("{SiteUrl}/_layouts/DispInvoice.aspx?List={ListId}&amp;ID={ItemId}&amp;action=3", ctx) + "')";

      var L_MenuItem_Correct_Action = "STSNavigate('" + ReplaceUrlTokens("{SiteUrl}/_layouts/DispInvoice.aspx?List={ListId}&amp;ID={ItemId}&amp;action=2", ctx) + "')";

 

    // Pobranie Content Type ID elementu ktory aktualnie ma tworzone menu

      var contentTypeId = GetAttributeFromItemTable(itemTable, "CId", "ContentTypeId");

     

      // Skrocenie pobranie content type do ID parent TYPE

      // zdefiniowanego wyzej

      var len = L_ContentTypeID_Tex.length;

      var mainContentType = contentTypeId.substring(0, len);

     

      // Jezeli nasz aktualny element nalezy do danego content type

      if(mainContentType.toUpperCase() == L_ContentTypeID_Tex.toUpperCase())

      {

          // Tworzymy element separacyjny

            CAMSep(m);

            // Dodajemy grupe

            var sm = CASubM(m, L_MenuGroup_Text, "", "", 1800);

            sm.id="ID_ActionInvoiceGroup";

 

        try

        {

            // Nasz request - to on nam zwroci to co chcemy

            // wiedziec na temat naszego menu                    

                var request;

                var reqUrl = ctx.HttpRoot + "/_layouts/GetEcbVisibility.aspx?ListID={ListId}&ItemID={ItemId}";

                // ReplaceUrlToken jest metoda zaimplementowana

                // w CORE.JS. Polecam jej uzycie jakoz gdy probowalem

                // sam sie odwolac do ItemId z itemTable lub za pomoca

                // currentItemId to zawsze dostawalem null, a w RaplaceUrlTokens

                // jakos to dziala :)

                reqUrl = ReplaceUrlTokens(reqUrl, ctx);

 

            // Wywolujemy XML http request.

            if (window.XMLHttpRequest)

            {

                request = new XMLHttpRequest();

                request.open("GET", reqUrl, false);

                request.send(null);

            }

 

            // Jesli cos mamy - obiekt to dzialamy

            if(request)

            {  

                var commands = request.responseXML.getElementsByTagName("Response");

                var isApproved = commands[0].getElementsByTagName("IsApproved")[0].firstChild.nodeValue;

                var isRejected = commands[0].getElementsByTagName("IsRejected")[0].firstChild.nodeValue;

               

                if(isApproved == 1 || isApproved == '1')

                {

                    // Dodajemy anuluj

                    CAMOpt(sm, L_MenuItem_Reject_Text, L_MenuItem_Reject_Action, L_MenuItem_Reject_Image, null, 110);

                }

                else if((isRejected == 0 || isRejected == '0') && (isApproved == 0 || isApproved == '0'))

                {

                    // Nie jest Anulowana ani nie jest Approved wiec dodajemy Approved

                    CAMOpt(sm, L_MenuItem_Approve_Text, L_MenuItem_Approve_Action, L_MenuItem_Approve_Image, null, 100);

                }

            }

        }

        catch(err)

        {

            // przechwutujemy blad by uzytkownik nie mial

            // info o Exception oraz by IE nie plus alertem ;)

            // alert(err.message);

        }

       

        // Przypisujemy elementy do grupy - zalozenie

        // jest takie iz korekcja zawsze istnieje

            CAMOpt(sm, L_MenuItem_Correct_Text, L_MenuItem_Correct_Action, L_MenuItem_Correct_Image, null, 120);

 

            CAMSep(m);

      }

}

</script>

Klikamy Save i potem Apply. Wynik widać odrazu:

Podsumowanie

Ucieszyłem się jak dziecko jak mi się to udało zrobić, osobiście jestem wstanie poświęcić 10 minut na napisanie ASPX i prostego JS by mieć dynamiczne menu w zależności od wartości element na liście.

Rozwiązani można rozwijać dalej, np. zrezygnować z sprawdzania Content Type z poziomu JS a robić to już z poziomu kodu. Jedynym minusem jest przeciążenie funkcji JS, którą nie tylko my możemy przeciążać – WSS wywoła ostatnie przeciążenie tej funkcji.

Przy rozwiązaniach dedykowanych, w ogóle bym się tym nie przejmował i robił to tak jak opisałem – chyba, że ktoś zna lepszy sposób :) z chęcią go poznam.

Jeżeli macie pytania to piszcie w komentarzach :)

Kod dla przykładu można pobrać z stąd.

 

Opublikowane 08 stycznia 09 02:09 przez Gutek

Komentarze:

# Waldek Mastykarz said on stycznia 8, 2009 14:13:

Super przyklad! Nie myslales o tym, zeby pisac po angielsku? Jestem pewien, ze jest sporo osob, ktore by z tego (i innych artykulow) chcialy skorzystac.

# Łukasz Skłodowski said on stycznia 8, 2009 23:55:

Bardzo fajny i z pewnością użyteczny przykład - proszę o więcej :)

# Gutek said on stycznia 9, 2009 12:12:

@Waldek

Dzieki :)

Myslalem, jednak jest w tym troche zachodu, a w polsce nie liczac liveoffice.pl nie ma zadnego bloga dot. SharePoint'a po polsku.

Zawsze z checia sluze pomoca w przetlumaczeniu.

Jest nowy rok, wiec mozna miec nowe postanowienie ;) postaram sie wiecej w tym roku po ang napiac :)

@Lukasz

Dzieki :)

Gutek

# Piotr said on lutego 10, 2009 19:35:

Świetny artykuł, bardzo mi pomógł, dzięki :)

Zaciekawiło mnie jeszcze coś innego. W twoim przykładzie jest faktura, czyli pewnie masz element główny i listę jej pozycji. SharePoint nie daje żadnych gotowych rozwiązań do budowania edycji  elementu zawierającego podlistę elementów. Ciekawi mnie bardzo jak do tego problemu podszedłeś. Sam w podobnej sytuacji napisałem WebParta, który jest edytowalnym GridView pozwalającym edytować listę pozycji. Jednak to rozwiązanie jest dosyć toporne i wymaga dużo kodowania. Na dodatek nie byłem w stanie podpiąć się pod przycisk OK na edycji i tylko w podglądzie elementu pozwalam edytować elementy listy. Może polecił byś jakiś sposób, albo pochwalił się swoim?

Pozdrawiam,

Piotr

# Gutek said on lutego 11, 2009 19:19:

@Piotr

na liscie rozwiazalem to za pomoca pola "wiele linii tekstu" i serializacji za pomoca Json.

Element pozycji faktury jest instancja klasy InvoiceEntry, zas lista pozycji to List<InvoiceEntry>.

Do tego napisalm mechanizm pobierania danych z aktualnego przetwazanego elementu i przechowywania go w sesji uzytkownika.

Zas na stronie mam ASP.NET RadGrid plus objectdatasouce ktory na Select zwraca mi liste List<InvoiceEntry>. Ma takze opcje dodawania i usuwania, ale juz na liscie.

Podczas klikania Zapisz dopiero zapisuje dane z sesji na liste oczywiscie konwertujac je za pomoca serializatora Json.

To rozwiazanie zajelo mi zeby nie sklamac 3-4h, zas inne rozwiazanie ktore chcialem na poczatku zrobic zajelo by okolo 12h - stworzenie wlasnego typu pola dla listy, ktore umozliwialoby przechowywanie wielu elementow. Cos w stylu MultiValueField lub tak jak People Lookup jest zrobiony. To co my widzimy to nazwa uzytkownika lub wielu uzytkownikow, zas to co ma People Lookup to touple ID, Nazwa uzytkownika.

Gutek

# Wojtek said on lutego 19, 2009 09:26:

Hmm. Pokazany przykład można zastosować w wielu systuacjach. mam jednak pewien problem (wynikający oczywiście z niewiedzy): pojawia sie menu tak jak w przykładzie, jednak po wybraniu pozycji 'Anuluj' nastepuje przekierowanie na stronę Dispinvoice.aspx z parametrami i komunikat: 'Nieznany błąd'.

Czy powyzszy przykład jest niekompletny? Chciałbym aby po wybraniu Anuluj w polu Status wpisywało sie Anulowano.

Tak w ogole dlaczego mamy przekierowanie na stronę DispInvoice.aspx skoro w \_layouts\ zapisywaliśmy plik 'GetEcbVisibility.aspx

# Gutek said on lutego 19, 2009 09:55:

@Wojtek

Plik GetEcbVisibility.aspx sluzy jako XmlHttpResponse. On generuje XMLa ktory nastepnie jest przetwarzany przez JS. Czyli ty z JavaScript prosisz GetEcbVisibility o zwrocenie XML'a na podstawie ktorego bedziesz mogl zadecydowac co dodasz i jak to obsluzysz w JavaScript na danej stronie.

Zas co do linku DispInvoice.aspx. Nie, jest to kompletny przyklad wyciagniety z systemu ktory pisalem - kompletny przyklad oprogramowania dynamicznie menu kontekstowego, nie obslugi faktur. W moim przypadku DispInvoice.aspx istnieje bo to stowrzylem, ale nic nie stoi na przeszkodzie bys w JavaScript wykonal przekirowanie na inny adres.

Czyli zamienic linijki:

var L_MenuItem_Approve_Action = "STSNavigate('" + ReplaceUrlTokens("{SiteUrl}/_layouts/DispInvoice.aspx?List={ListId}&amp;ID={ItemId}&amp;action=1", ctx) + "')";

     var L_MenuItem_Reject_Action = "STSNavigate('" + ReplaceUrlTokens("{SiteUrl}/_layouts/DispInvoice.aspx?List={ListId}&amp;ID={ItemId}&amp;action=3", ctx) + "')";

     var L_MenuItem_Correct_Action = "STSNavigate('" + ReplaceUrlTokens("{SiteUrl}/_layouts/DispInvoice.aspx?List={ListId}&amp;ID={ItemId}&amp;action=2", ctx) + "')";

na cos gdzie chcesz przekierowac lub na JavaScript alert np.:

var L_MenuItem_Correct_Action = "alert('test na korekcji')";

Pozdrawiam,

  Gutek

Komentarze anonimowe wyłączone