The third test checks if updating an existing persistent object works as expected. Note that the actual modification, the voteUp, has to be done inside a unit of work to a registered object for it to be picked up by Glorp.

6.

RedditSession

We are almost ready to start writing the GUI of our actual web application. Seaside web applications always have a session object that keeps the application’s state during the user’s interaction with it. We need to extend that session with a database session. Our WASession subclass RedditSession has an instance variable called glorpSession to hold a Glorp session to the database.

glorpSession

glorpSession ifNil: [ glorpSession := self newGlorpSession ].

glorpSession accessor isLoggedIn

ifFalse: [ glorpSession accessor login ].

^ glorpSession newGlorpSession

| session |

session := RedditDatabaseResource session.

session accessor logging: true.

^ session unregistered

super unregistered.

self teardownGlorpSession teardownGlorpSession

self glorpSession logout

Note how we are using lazy initialization in the glorpSession accessor. In newGlorpSession we’re making use of our RedditDatabaseResource. The unregistered is a hook called by Seaside whenever a session expires, we use it clean up our Glorp session by doing a log out.

7.

RedditWebApp

We can now start with the web app itself. There are 4 sections in this single page app: a header or title section, some action links, a list of some of the highest or top ranking links and a list of some of the latest or most recent links.

We create a WAComponent subclass called RedditWebApp. This will become our central or root web app component. We start by writing the rendering methods.

renderContentOn: html

html heading: 'Reddit.st'.

html heading

level3;

with: 'In 10 cool Pharo classes'.

self renderActionsOn: html.

self renderHighestRankingLinksOn: html.

self renderLatestLinksOn: html renderActionsOn: html

html paragraph: [

html anchor

callback: [ ];

with: 'Refresh'.

html anchor

callback: [ self inform: 'Not yet implemented' ];

with: 'New Link' ] renderHighestRankingLinksOn: html

html heading

level2;

with: 'Highest Ranking Links'.

html orderedList: [

self highestRankingLinks do: [ :each |

self renderLink: each on: html ] ] renderLatestLinksOn: html

html heading

level2;

with: 'Latest Links'.

html orderedList: [

self latestLinks do: [ :each |

self renderLink: each on: html ] ] renderLink: link on: html

html listItem: [

html anchor

url: link url;

title: link url;

with: link title.

html text: ' Posted ',

(self class durationString: link age),

' ago. '.

html text: link points asString, ' points. '.

html anchor

callback: [ self voteUp: link ];

title: 'Vote this link up';

with: 'Up'.

html space.

html anchor

callback: [ self voteDown: link ];

title: 'Vote this link down';

with: 'Down' ]

Starting with the main renderContentOn: method, the rendering of each section is delegated to its own method. Note how renderLink:on: is used 2 times. For now, we’re not yet implementing the ‘New Link’ action. We are generating HTML using a DSL.

Our rendering methods depend on 5 extra methods: highestRankingLinks, latestLinks, the class method durationString: and the actions methods voteUp: and voteDown:. Only the first three are needed to render the page itself.

highestRankingLinks

| query |

query := (Query readManyOf: RedditLink)

orderBy: [ :each | each points descending ];

limit: 20;

yourself.

^ self session glorpSession execute: query latestLinks

| query |

query := (Query readManyOf: RedditLink)

orderBy: [ :each | each created descending];

limit: 20;

yourself.

^ self session glorpSession execute: query durationString: duration

^ String streamContents: [ :stream |

| needSpace printer |

needSpace := false.

printer := [ :value :word |

value isZero

ifFalse: [

needSpace ifTrue: [ stream space ].

stream nextPutAll: (value pluralize: word).

needSpace := true ] ].

printer value: duration days value: 'day'.

printer value: duration hours value: 'hour'.

printer value: duration minutes value: 'minute'.

duration < 60 seconds

ifTrue: [ printer value: duration seconds value: 'second' ].

duration < 1 second

ifTrue: [ stream nextPutAll: 'less than a second' ] ]

In highestRankingLinks and latestLinks, we explicitely build up and execute Query objects with some more advanced options. For duration string conversion we use the powerful pluralize method. With these in place we can already render the page. Since we did not yet add any CSS styling, the result will look rather dull.

voteUp: link

self session glorpSession

inUnitOfWorkDo: [ :session |

session register: link.

link voteUp ] voteDown: link

self session glorpSession

inUnitOfWorkDo: [ :session |

session register: link.

link voteDown ]

Voting links up or down is trivial, like with our database test, we only have to make sure to do the object modifications inside a Glorp unit of work and the actual SQL update will be done automatically.

8. RedditFileLibrary

To style our web app, we’ll be using CSS. This CSS code references one small GIF for its background gradient. We need to make sure our application makes use of the CSS file and that we serve the actual files. Seaside can serve these files in a couple of ways, we’ll be using the FileLibrary approach. This is a class where each resource served is implemented as a method.

Our WAFileLibrary subclass RedditFileLibrary will thus have 2 methods: mainCss and bgGif, returning a string and bytes respectively. These are long methods which are not our main focus so we do not list them here. Based on some naming conventions, Seaside will figure out what mime types to use.

updateRoot: anHtmlRoot

super updateRoot: anHtmlRoot.

anHtmlRoot title: 'Reddit.st'.

anHtmlRoot stylesheet url: (RedditFileLibrary urlOf: #mainCss)

By implementing the updateRoot: hook method on RedditWebApp, we can set our page title and CSS. Note again how everything happens in Pharo. To install a Seaside application, a class side initialize method is typically used.

initialize

(WAAdmin register: self asApplicationAt: 'reddit')

preferenceAt: #sessionClass put: RedditSession;

addLibrary: RedditFileLibrary

We register our application under the handler ‘reddit’ so its URL will become something like http://localhost:8080/reddit. Then we tell it to use our custom session class and finally add our file library. We now have a nicely styled, working web app.

9.

RedditLinkEditor

One of Seaside’s main advantages over other web application frameworks is its support for components. Especially for large and complex projects this makes a huge difference. We’ll be introducing a new component to allow the user to enter the necessary information when adding a new link.

Consider the difference between first and second screenshot of our web app in action: when the user clicks the ‘New Link’ anchor, we’ll add an editor just below (while hiding the ‘New Link’ anchor). The editor will have its own ‘Save’ and ‘Cancel’ buttons. Both of these will dismiss the editor, saving or cancelling the new link.

How is Seaside’s component model powerful ? As we will see next, the component is written without any knowledge of where it will be used. Its validation logic is independent. It is used just by embedding it and by wiring it to its user in a simple way. The subcomponent functions indepedently from its embedding parent while each keeps its own state: whether the component is visible or not, you can keep on voting links up or down, and doing so will not alter the contents of the component.

To prove our point, we’ll be using yet another component inside our link editor: a simple CAPTCHA component. This will be implemented in the final section, but is used here as a black box.

The first step is to make the necessary additions and modifications to RedditWebApp to accommodate the link editor component. We add an instance variable called linkEditor with and its accessors.

renderContentOn: html

html heading: 'Reddit.st'.

html heading

level3;

with: 'In 10 cool Pharo classes'.

self renderActionsOn: html.

self linkEditor ifNotNil: [ html render: self linkEditor ].

self renderHighestRankingLinksOn: html.

self renderLatestLinksOn: html renderActionsOn: html

html paragraph: [

html anchor

callback: [ ];

with: 'Refresh'.

self linkEditor

ifNil: [

html anchor

callback: [ self showNewLinkEditor ];

with: 'New Link' ] ] showNewLinkEditor

self linkEditor: RedditLinkEditor new.

self linkEditor

onAnswer: [ :answer |

answer

ifTrue: [

self session glorpSession

inUnitOfWorkDo: [ :session |

session register: self linkEditor createLink ] ].

self linkEditor: nil ] children

^ self linkEditor notNil

ifTrue: [ Array with: self linkEditor ]

ifFalse: [ super children ]

There are 2 possible states: either we have a link editor subcomponent visible or not. So the main renderContentOn: method conditionally asks the link editor to render itself. Likewise, in renderActionsOn: the ‘New Link’ anchor is only rendered when there is no link editor yet.

In the showNewLinkEditor action method we instanciate our subcomponent and hook it up. We could have reused just one instance, creating a new one is easier and clearer. The wiring is done by supplying a block to onAnswer:. A component can answer a value, in our case true or false for save or cancel respectively. So when the link editor answers true, we save a new link object and hide the editor.

In Seaside, the children method is a hook method that has to be implemented to list all subcomponents. Again this happens conditionally.

We can now implement the component itself: RedditLinkEditor is a subclass of WAComponent with 3 instances variables and their accessors: url, title and captcha.

renderContentOn: html

html form: [

html paragraph: 'Enter a URL &title for the link to add:'.

html textInput

size: 48;

title: 'The URL of the new link';

on: #url of: self.

html textInput

size: 48;

title: 'The title of the new link';

on: #title of: self.

html render: self captcha.

html submitButton on: #cancel of: self.

html submitButton on: #save of: self ] initialize

super initialize.

self

url: 'http://';

title: 'title';

captcha: WARedditCaptcha new children

^ Array with: self captcha updateRoot: anHtmlRoot

super updateRoot: anHtmlRoot.

anHtmlRoot title: 'Reddit.st — Submit a new link' cancel

self answer: false save

self isUrlMissing

ifTrue: [

^ self inform: 'Please enter an URL' ].

self isTitleMissing

ifTrue: [

^ self inform: 'Please enter a title' ].

self captcha isSolved

ifFalse: [

^ self inform: 'Please answer the correct sum using digits' ].

self isUrlValid

ifFalse: [

^ self inform: 'The URL you entered did not resolve' ].

self answer: true isTitleMissing

^ self title isNil or: [

self title isEmpty or: [ self title = 'title' ] ] isUrlMissing

^ self url isNil or: [

self url isEmpty or: [ self url = 'http://' ] ] isUrlValid

^ [

ZnClient new

get: self url;

isSuccess ]

on: Error

do: [ false ] createLink

^ RedditLink withUrl: self url title: self title

Most of the code should be familiar by now. New is how cancel and save use answer: to return to whoever called upon this component. Before save returns successfully, a number of validation tests are done. When one of these tests fails, a message is shown and the operation is aborted. The isUrlValid method actually tries to resolve the URL. Finally, createLink instantiates a new RedditLink instance based on the valid fields entered by the user. Note how the CAPTCHA is used as a true black box component.

10.

RedditCaptcha

The last and simplest web component is a CAPTCHA that presents a simple addition in words. This component does not need answer logic. RedditCaptcha is again a WAComponent subclass with the following instance variables and accessors: x, y and sum.

renderContentOn: html

self x: 10 atRandom.

self y: 10 atRandom.

html paragraph:

('CAPTCHA: How much is {1} plus {2} ?'

format: { self x asWords. self y asWords }).

html textInput

title: 'Type the answer using digits';

on: #sum of: self initialize

super initialize.

self x: 0; y: 0; sum: 0 isSolved

^ self sum asInteger = (self x + self y)

Each time the CAPTCHA is rendered, x and y get a new random value between 1 and 10. Next, the addition is presented in words. The isSolved method checks if the user answered correctly.

Appendix

The source code discussed in this article is available from SmalltalkHub in a project called Reddit. It was written for Pharo 3.0. You should load the code using its Metacello configuration, because Seaside and Glorp have to be loaded as well. These are both heavy packages that take a while to load and compile.

Gofer it

smalltalkhubUser: 'SvenVanCaekenberghe' project: 'Reddit';

configuration;

loadStable.

You will have to configure the connection to your PostgreSQL instance. One way to do so it to edit the method RedditDatabaseResource class>>#createLogin. After you have done so, make sure to clear the cached version by doing RedditDatabaseResource resetLogin.

Alternatively, you can download a prebuilt image containing everything as the latest successful build artifact from the Pharo Contribution CI job called Reddit.