Dynamik für den Feedreader

Im letzten Teil haben wir eine Wicket-Komponente entwickelt, die Newsfeeds schick anzeigt. Die nötigen Daten haben wir mittels Rome aus echten Feeds erhalten. Heute wollen wir unsere Newsansicht optimieren, denn diese ist noch reichlich unübersichtlich. Dabei bringen wir auch Dynamik mittels JavaScript und Third-Party-Libraries, in unserem Fall jQuery, ins Spiel.

Mehrseitigkeit

Doch bevor es dynamisch wird, sorgen wir ersteinmal dafür, dass nicht alle News auf einer Seite stehen. Für solche Zwecke verwenden wir eine PageableListView in Verbindung mit einem PageNavigator. Damit nun also immer maximal 15 News auf einmal angezeigt werden, passen wir unsere Komponente entsprechend an:

public class NewsList extends Panel {

	public NewsList(String id, List<News> news) {
		super(id);
		
		PageableListView<News> newsList = new PageableListView<News>("news", news, 15) {
			@Override
			protected void populateItem(ListItem<News> item) {
				final News news = item.getModelObject();
				item.add(new AttributeModifier("class", true, new AbstractReadOnlyModel<String>() {
					@Override
					public String getObject() {
						return news.getGame().getName().toLowerCase().replace(' ', '-');
					}
				}));
				item.add(new ExternalLink("link", news.getUrl(), news.getTitle()).setEscapeModelStrings(false));
				item.add(new Label("page", news.getPage()).setRenderBodyOnly(true));
				item.add(new MultiLineLabel("description", news.getDescription()).setEscapeModelStrings(false));
				item.add(new DateTimeLabel("date", new PropertyModel<Date>(news, "date")));
			}
		};
		add(newsList);
		add(new PagingNavigator("pagenavigation", newsList));
	}
}

Die Änderung am Markup ist recht trivial:

<html>
<head>
<wicket:head><wicket:link>
	<link rel="stylesheet" type="text/css" href="newslist.css" />
</wicket:link></wicket:head>
</head>

<body>
<wicket:panel>
	<ul class="newslist">
		<li wicket:id="news">
			<span wicket:id="date" class="date">01.01.1970</span>
			<span wicket:id="page">Seite</span>: <a wicket:id="link"></a>
			<div wicket:id="description" class="description">Beschreibung</div>
		</li>
	</ul>
	<div wicket:id="pagenavigation"></div>
</wicket:panel>
</body>
</html>

Ajax

Ein nächster störender Punkt sind die unterschiedlichen Beschreibungstexte. Während es in manchen Feeds nur kurze Beschreibungen gibt, findet sich in anderen der komplette Newstext mit Bildern und allem drum und dran wieder. Dadurch geht viel Übersicht verloren, da die Abstände zwischen den News sehr uneinheitlich sind und zumeist will man sowieso nur wissen, wo es etwas Neues gibt und dann dem Link folgen, um den Text auf der Ursprungsseite zu lesen.

Im einfachsten Fall also könnte man die Beschreibung einfach wegfallen lassen, aber der ein oder andere will sich ja vielleicht doch ein Bild machen, um festzustellen, ob er die News lesen will oder sie vielleicht sogar schon kennt. An dieser Stelle bietet es sich an, die Beschreibung optional einblenden zu lassen. Um mal ein Gefühl dafür zu kriegen, wie man Wicket und Ajax verbindet, werden wir erstmal Ajax verwenden. Klar, unsere News braucht also erstmal einen Link (ich habe die HTML-Datei mal auf das wesentliche gekürzt, da wir sie eben schon gesehen haben):

		<li wicket:id="news">
			<span wicket:id="date" class="date">01.01.1970</span>
			<a wicket:id="expand">[+]</a>
			<span wicket:id="page">Seite</span>: <a wicket:id="link"></a>
			<div wicket:id="description" class="description">Beschreibung</div>
		</li>

Im Java-Code gibt es nun einige Neuerungen, die ich gleich erklären werde.

public class NewsList extends Panel {

	public NewsList(String id, List<News> news) {
		super(id);
		
		PageableListView<News> newsList = new PageableListView<News>("news", news, 15) {
			@Override
			protected void populateItem(ListItem<News> item) {
				final News news = item.getModelObject();
				item.add(new AttributeModifier("class", true, new AbstractReadOnlyModel<String>() {
					@Override
					public String getObject() {
						return news.getGame().getName().toLowerCase().replace(' ', '-');
					}
				}));
				item.add(new ExternalLink("link", news.getUrl(), news.getTitle()).setEscapeModelStrings(false));
				item.add(new Label("page", news.getPage()).setRenderBodyOnly(true));
				
				MultiLineLabel description = new MultiLineLabel("description", news.getDescription());
				description.setEscapeModelStrings(false);
				description.setOutputMarkupPlaceholderTag(true);
				description.setVisible(false);
				item.add(description);
				item.add(new ChangeVisibilityLink("expand", description));

				item.add(new DateTimeLabel("date", new PropertyModel<Date>(news, "date")));
			}
		};
		newsList.setReuseItems(true);
		add(newsList);
		add(new PagingNavigator("pagenavigation", newsList));
	}
	
	class ChangeVisibilityLink extends AjaxFallbackLink<String> {
		
		private Component component;
		
		public ChangeVisibilityLink(String id, Component component) {
			super(id);
			this.component = component;
		}

		@Override
		public void onClick(AjaxRequestTarget target) {
			component.setVisible(!component.isVisible());
			if (null != target) {
				target.addComponent(component);
			}
		}
	}
}

Das MultiLineLabel, das die Beschreibung darstellt, erhält jetzt etwas mehr Aufmerksamkeit. Der erste neue Aufruf ist setOutputMarkupPlaceholderTag(true), der dafür sorgt, dass ein Platzhalter im Code gelassen wird, wenn das Element nicht sichtbar ist. In der nächsten Zeile wird die Beschreibung unsichtbar gemacht, was normalerweise bedeutet, dass das gesamte HTML-Element in der gerenderten Seite gar nicht mehr vorkommt. Dann wüsste das JavaScript aber nicht, wo es den Beschreibungstext hinschreiben sollte, wenn der Link angeklickt wird – deswegen brauchen wir den Platzhalter, der dann einfach ersetzt wird.

Schließlich wird noch der neue Link hinzugefügt, der fürs Ändern der Sichtbarkeit der Beschreibung zuständig ist. Diesen finden wir als innere Klasse in unserer Komponente und abgeleitet ist sie von AjaxFallbackLink, wodurch gewährleistet ist, dass die Funktionalität sowohl mit, als auch ohne JavaScript gegeben ist. Im Konstruktor speichern wir das Element, dessen Sichtbarkeit geändert werden soll. Wichtig ist die Methode onClick(), die wir überschreiben müssen. Dort notieren wir die Anweisungen, die beim Klicken des Links ausgeführt werden sollen, wir Ändern also die Sichtbarkeit. Außerdem wird ein AjaxRequestTarget übergeben, das null ist, wenn es sich um keinen Ajax-Request handelt. Diesem Target werden die Elemente übergeben, die neu geladen werden sollen, in unserem Fall ist das also einfach nur die Beschreibung.

Ich hatte anfangs das Problem, dass zwar die Ajax-Funktionalität wunderbar lief, aber wenn ich JavaScript deaktivierte, dann geschah nix weiter, als dass die Seite neu geladen wurde. Beschreibungen wurden nicht sichtbar. Eine ganze Weile musste ich herumrätseln, bis ich auf die Lösung stieß: Bei jedem Request erneuert sich die Liste von selbst, da sich ja Listenelemente geändert haben könnten. Dabei wird auch populateItem() von neuem aufgerufen und unter anderem ein völlig neues MultiLineLabel erstellt – die Sichtbarkeitsänderung bezieht sich also auf ein Element, dass es beim erneuten Rendern gar nicht mehr gibt. Deswegen findet sich noch der Aufruf von newsList.setReuseItems(true); in unserer Komponente, denn die verhindert, dass sich die ListView neu erstellt.

Mehr Dynamik

Eigentlich sieht jetzt alles toll aus. Die Beschreibungen sind eingeklappt und per Ajax können wir sie ausklappen. Allerdings dauert das unter Umständen eine Weile, wenn wir nicht lokal arbeiten, sondern übers Internet. Da wäre es doch sinnvoller, die Beschreibung gleich zu laden und dann nur mit normalem JavaScript die Sichtbarkeit zu ändern. Das ist auch mein Ziel für heute – der Ajax-Kram war eher mal Neugierde, weil ich das auch mal probieren wollte.

public class NewsList extends Panel {

	public NewsList(String id, List<News> news) {
		super(id);
		
		PageableListView<News> newsList = new PageableListView<News>("news", news, 15) {
			@Override
			protected void populateItem(ListItem<News> item) {
				final News news = item.getModelObject();
				item.add(new AttributeModifier("class", true, new AbstractReadOnlyModel<String>() {
					@Override
					public String getObject() {
						return news.getGame().getName().toLowerCase().replace(' ', '-');
					}
				}));
				item.add(new ExternalLink("link", news.getUrl(), news.getTitle()).setEscapeModelStrings(false));
				item.add(new Label("page", news.getPage()).setRenderBodyOnly(true));
				
				MultiLineLabel description = new MultiLineLabel("description", news.getDescription());
				description.setEscapeModelStrings(false);
				item.add(description);
				item.add(new ChangeVisibilityLink("expand", description));
				
				item.add(new DateTimeLabel("date", new PropertyModel<Date>(news, "date")));
			}
		};
		newsList.setReuseItems(true);
		add(newsList);
		add(new PagingNavigator("pagenavigation", newsList));
	}
	
	class ChangeVisibilityLink extends Link<String> {
		
		private Component component;
		private AttributeModifier visibilityModifier;
		private boolean visible;
		
		public ChangeVisibilityLink(String id, Component component) {
			super(id);
			this.component = component;
			visibilityModifier = new AttributeModifier("style", true, new Model<String>("display:none;"));
			visible = false;
			
			component.add(visibilityModifier);
			add(new ChangeVisibilityBehavior(component));
		}

		@Override
		public void onClick() {
			if (visible) {
				component.add(visibilityModifier);
				visible = false;
			} else {
				component.remove(visibilityModifier);
				visible = true;
			}
		}
	}
	
	class ChangeVisibilityBehavior extends AbstractBehavior {
		
		private Component component;
		
		public ChangeVisibilityBehavior(Component component) {
			this.component = component;
			component.setOutputMarkupId(true);
		}
		
		@Override
		public void onComponentTag(Component component, ComponentTag tag) {
			super.onComponentTag(component, tag);
			String action = "var n = document.getElementById('" + this.component.getMarkupId() + "'); ";
			action += "var d = n.style.display; n.style.display = d == 'block' ? 'none' : 'block'; return false;";
			tag.put("onclick", action);
		}
	}
}

Wieder wurden zahlreiche Änderungen durchgeführt. Zuersteinmal wird die Beschreibung nicht unsichtbar gemacht, denn schließlich soll das MultiLineLabel mitsamt des Beschreibungstextes gerendert werden. Den Aufruf von setOutputMarkupPlaceholderTag(true) können wir uns auch sparen, da das Element ja auf jeden Fall komplett angezeigt wird.

Der Link zum Ändern der Sichtbarkeit erbt nicht mehr von AjaxFallbackLink, sondern ist nur noch ein ganz normaler Link, der im Falle von deaktiviertem JavaScript immer noch seinen Dienst tun soll. Deshalb erhält die Komponente einen AttributeModifier, der das nötige style-Attribut an Ort und Stelle bringt. Wird der Link betätigt, so wird die onClick()-Methode ausgeführt (wie bisher auch) und dort wird je nach dem, ob die Komponente sichtbar ist, der Modifikator hinzugefügt oder entfernt. Mit isVisible() und setVisible() können wir hier ja nicht arbeiten, da wie bereits erwähnt das gesamte Element immer im HTML-Code erscheinen soll.

Doch das eigentliche Ziel war es ja, die Funktionalität ohne Request an den Server umzusetzen. Dafür machen wir uns eine Behavior zunutze, mit der man allerlei Dinge anstellen kann. Im einfachsten Fall wird damit ein Attribut zum HTML-Element hinzugefügt, ähnlich wie beim AttributeModifier, aber die Möglichkeiten sind vielfältig. So wäre es auch möglich, das Tag auf noch ganz andere Arten zu verändern oder Anweisungen in den Kopf der HTML-Datei zu schreiben. Wir aber beschränken uns darauf, etwas JavaScript ins onclick-Attribut zu geben. Das sieht auf den ersten Blick zugegebenermaßen nicht besonders schick aus, aber es erfüllt seinen Zweck. Die letzte JavaScript-Anweisung, return false;, sorgt dafür, dass das Klicken auf den Link keinen Request an den Server zur Folge hat – sonst würde die Seite anschließend neu geladen werden.

Die Funktionalität ist jetzt gegeben, aber ganz zufrieden bin ich noch nicht. Der selbstgeschriebene JavaScript-Code ist nicht besonders schick und das Auf- bzw. Zuklappen ist irgendwie langweilig. Eine JavaScript-Bibliothek soll dabei beide Fliegen mit einer Klappe schlagen. Ich habe mich für jQuery entschieden – einfach, weil es irgendwie einen sympathischen Eindruck auf mich gemacht hat. Auch gut finde ich die Möglichkeit, mittels CSS-Selektoren auf mehrere HTML-Elemente zugreifen zu können, auch wenn ich das heute nicht nutzen will.

Wir müssen nur unsere Behavior anpassen, um zum gewünschten Erfolg zu kommen:

	class ChangeVisibilityBehavior extends AbstractBehavior {
		
		private Component component;
		
		public ChangeVisibilityBehavior(Component component) {
			this.component = component;
			component.setOutputMarkupId(true);
		}
		
		@Override
		public void renderHead(IHeaderResponse response) {
			super.renderHead(response);
			response.renderJavascriptReference(new ResourceReference(NewsList.class, "jquery-1.2.6.min.js"));
		}
		
		@Override
		public void onComponentTag(Component component, ComponentTag tag) {
			super.onComponentTag(component, tag);
			tag.put("onclick", "$('#" + this.component.getMarkupId() + "').slideToggle(200);return false;");
		}
	}

In der Methode renderHead sorgen wir dafür, dass die JavaScript-Bibliothek verwendet wird. Diese habe ich einfach in das selbe Verzeichnis kopiert, in dem auch die Klassen und HTML-Dateien liegen. Wir müssen uns keine Sorgen machen, dass sie nun 15 mal eingebunden wird – Wicket kümmert sich darum, dass es nur einmal gemacht wird. In onComponentTag wird nun mittels eines Befehls ein Slide-Effekt ausgelöst und der Code sieht schon viel aufgeräumter auf.

Mit jQuery kann man auch auf andere Art und Weise Events an Elemente koppeln und auf diese Elemente kann man per Selektoren, wie man sie aus CSS kennt, zugreifen. Das probiere ich aus, indem ich das Plus vom Aufklapp-Link durch ein Minus ersetze, wenn geklickt wird. Dazu schreibe ich eine neue Behavior:

	class ToggleTextBehavior extends AbstractBehavior {
		
		private String cssSelector;
		private String text;
		private String otherText;
		
		public ToggleTextBehavior(String cssSelector, String text, String otherText) {
			this.cssSelector = cssSelector;
			this.text = text;
			this.otherText = otherText;
		}
		
		public void renderHead(IHeaderResponse response) {
			super.renderHead(response);
			response.renderJavascriptReference(new ResourceReference(NewsList.class, "jquery-1.2.6.min.js"));
			StringBuilder javascript = new StringBuilder();
			javascript.append("$(document).ready(function() {\n");
			javascript.append("  $('" + cssSelector + "').toggle(function() {\n");
			javascript.append("    $(this).empty().append('" + otherText + "');\n");
			javascript.append("  }, function() {\n");
			javascript.append("    $(this).empty().append('" + text + "');\n");
			javascript.append("  })\n");
			javascript.append("});");
			response.renderJavascript(javascript, null);
		}
	}

An welches Element sie gehangen wird, ist unerheblich, da hier nur der Header verändert wird. Man könnte es an die ListView hängen, aber auch direkt an den ChangeVisibilityLink und natürlich muss man sich auch hier keine Sorgen machen, dass der Code evtl. mehrmals angezeigt wird.

newsList.add(new ToggleTextBehavior("ul.newslist > li a.expand", "[+]", "[-]"));

Damit dieser hier verwendete Selektor funktioniert, braucht der Aufklapp-Link noch eine CSS-Klasse namens „expand“. Abgesehen davon spart man sich viele verschiedene IDs an den Tags – auch den Aufklapp-Effekt der Beschreibung hätten wir auf diese Art umsetzen können. Wer nicht zig Behaviors selber schreiben will, der kann sich mal Wickext ansehen. Das ist ein Projekt, das versucht, Wicket und jQuery zusammenzuführen, so dass der Anwender dann keinen eigenen JavaScript-Code mehr schreiben muss.

Fazit

Damit haben wir heute schon etwas erreicht. Die News sind wesentlich übersichtlicher und werden aufs Nötigste reduziert – Titel, Link und Zeitpunkt. Optional kann man sich mit einem Klick die Beschreibung anzeigen lassen, die stylish aufklappt oder, falls JavaScript deaktiviert ist, über einen normalen Request abläuft, damit niemand außen vor gelassen wird. Außerdem haben wir gesehen, wie einfach es ist, ein wenig Ajax in die Anwendung zu bringen, auch wenn wir diesen Ansatz dann wieder verworfen haben. Das gesamte Projekt könnt ihr euch natürlich herunterladen.

Was kommt als nächstes? Abgesehen davon, dass der Code nicht kommentiert ist und manche der inneren Klassen durchaus in eigene ausgelagert werden könnten, gibt es auch noch immer funktionale Schwachstellen. Der Feed wird immer beim Start des Servers geladen und dann nie wieder aktualisiert. Alle News werden zusammen mit der WebPage serialisiert und in der Session hinterlegt, was unnützer Verbrauch von Ressourcen ist. Es gibt also noch jede Menge zu tun.

Der dritte Teil geht das Problem mit JPA und Hibernate an: Persistenz für den Feedreader.

Tags:

2 Pingbacks

  1. Wicket Feedreader - rattlab.net 29. September 2008 um 21:27
  2. Persistenz für den Feedreader - rattlab.net 28. Oktober 2008 um 22:01

3 Antworten

  1. Lionel says:

    Hi there !

    I’m WickeXt project owner and I just saw your post about our Wicket – jQuery – jQueryUI integration. (Sorry, I don’t speak german, I traduced your page to see what this page talked about).

    Anyway, do not hesitate to ask questions about WickeXt, we are open to any contribution / question about the framework.

    Thanks for the post !
    See u guys

  2. ex-ratt says:

    In this post I show some basic JavaScript integration into a custom Wicket component and I used jQuery for that. WickeXt is just named for further reference just in case someone want to do more with jQuery and Wicket. In my next project (which I will start somewhere in the future, when I have more time) I wanna use Wicket and jQuery so it is very likely I am going to use WickeXt. A few moments ago I saw your first tutorial (until then I only took a look at the code) and I am waiting for more :p

    Do you plan to replace existing Wicket-Ajax-Stuff (like AjaxFallbackLabel) with own jQuery implementations, so there is only one library used or are you going to add new stuff only?

    And sorry for the post not being about WickeXt, but maybe I will make a little tutorial or will extend the example of this post to show the use of your project.

  3. Lionel says:

    Hi,

    Thanks for your reply. I’m not planning to replace Wicket’s JavaScript native library, but I want to provide a nice way to use jQuery and jQuery UI with Wicket. WickeXt is used on some projects of my company and the next release will come with new features to generate more „jQuery oriented“ JavaScript (I’ll send you a message with a snippet of the code that one can write very easily, respecting both Java and JavaScript concepts).

    See u !

    Lionel
    http://code.google.com/p/wickext/

Hinterlasse eine Antwort