CRUD App using composition - kendmaclean/pharo12 GitHub Wiki

DRAFT April 18, 2024

Note: Pharo is continuously evolving - therefore make sure you use a Pharo 12 image with this example.

(Created with the help of Google AI Studio)

Let's create a simple CRUD (Create, Read, Update, Delete) application example using Pharo 12 and the Spec2 UI framework, and composition rather than inheritance (Note: Pharo recommends using subclassing)

We'll create an application to manage a list of simple "Person" objects, each having a name and an age.

1. The Domain Model (Person)

First, define the object we want to manage.

Object << #Person
    slots: { #name . #age };
    package: 'SpecCRUD-Example-UI-Composition'

Add accessors and a basic initializer:

Person >> initialize
    super initialize.
    name := ''.
    age := 0

Person >> name
    ^ name

Person >> name: aString
    name := aString

Person >> age
    ^ age

Person >> age: anInteger
    age := anInteger

Person >> displayString
    "Used for displaying in lists"
    ^ name, ' (', age asString, ')'

Person >> printOn: aStream
    "Standard printing"
    super printOn: aStream.
    aStream nextPut: $(.
    self displayString printOn: aStream.
    aStream nextPut: $)

2. The Repository (PersonRepository)

We need a place to store our Person objects. For simplicity, we'll use an in-memory repository implemented as a Singleton.

Instance side:

Object << #PersonRepository
    slots: { #persons };
    package: 'SpecCRUD-Example-UI-Composition'

Class side:

Object class << PersonRepository class
	slots: { #SoleInstance }

Implement the Singleton pattern and CRUD methods:

Class side:

PersonRepository class >> initialize
    "Initialize the singleton instance"
    SoleInstance := nil

PersonRepository class >> current
    "Answer the singleton instance, creating it if necessary"
    ^ SoleInstance ifNil: [ SoleInstance := self new ]

PersonRepository class >> reset
    "Reset the repository (useful for testing)"
    SoleInstance := nil

Instance side:

PersonRepository >> initialize
    "Initialize the instance with an empty collection"
    super initialize.
    persons := OrderedCollection new

PersonRepository >> allPersons
    "Answer all persons, sorted by name"
    ^ persons sorted: [ :a :b | a name < b name ]

PersonRepository >> addPerson: aPerson
    "Add a new person"
    persons add: aPerson

PersonRepository >> removePerson: aPerson
    "Remove a person"
    persons remove: aPerson ifAbsent: []

PersonRepository >> updatePerson: oldPerson with: newPersonData
    "Find oldPerson and update its state with data from newPersonData.
     Note: This assumes newPersonData is a Person object holding the new values.
     A more robust approach might involve finding by a unique ID if names/ages can change."
    | personToUpdate |
    personToUpdate := persons detect: [ :p | p == oldPerson ] ifNone: [ ^ self ]. "Find the exact instance"
    personToUpdate name: newPersonData name.
    personToUpdate age: newPersonData age.

3. The Editor Dialog (PersonEditor)

This dialog will be used for both creating and editing Person objects.

Object << #PersonEditor
    slots: { #person . #nameInput . #ageInput . #okButton . #cancelButton . #title . #dialogPresenter};
    package: 'SpecCRUD-Example-UI-Composition'

Implement the UI layout and logic:

Class side:

PersonEditor class >> openModalFor: aPerson title: aString
    "Open the editor modally.
     Uses composition: Creates a PersonEditor instance which manages a SpDialogPresenter.
     Returns the edited/created person if OK is pressed, nil otherwise."
    | editor result |
    editor := self new.
    editor title: aString.
    editor person: (aPerson ifNil: [ Person new ] ifNotNil: [ aPerson copy ]). "Edit a copy"

	 result := editor openModal. "Calls the instance-side openModal"
    ^ result ifNotNil: [ editor person ] ifNil: [ nil ] 

Instance side:

PersonEditor >> initialize
    "Initialize the editor logic and create the managed presenters"
    super initialize.
    self initializePresenters.
    self initializeLayout.
    "self initializeWindowAspects." "does not work with openModal"

PersonEditor >> initializeLayout
    "Define the layout and set it on the managed dialog presenter"
    | layout |
    layout := SpBoxLayout newVertical
			add: 'Name:' expand: false;
			add: nameInput;
			add: 'Age:' expand: false;
			add: ageInput;
			add: (SpBoxLayout newHorizontal add: okButton; add: cancelButton; yourself) expand: false;
			yourself.
    dialogPresenter layout: layout

PersonEditor >> initializePresenters
    "Create the UI components - these are now regular Spec presenters"
    dialogPresenter := SpDialogPresenter new.
			
    nameInput := SpTextInputFieldPresenter new. "same as 'self newTextInput' when subclassing SpDialogPresenter "
    ageInput := SpNumberInputFieldPresenter new "same as 'self newNumberInput' when subclassing SpDialogPresenter "
                    minimum: 0;
                    maximum: 150;
                    yourself.

    okButton := SpButtonPresenter new "same as 'self newButton' when subclassing SpDialogPresenter "
                    label: 'OK';
						  icon: self getIconOk;
                    action: [ self ok ].

    cancelButton := SpButtonPresenter new
                    label: 'Cancel';
						  icon: self getIconCancel;
                    action: [ self cancel ].

    dialogPresenter focusOrder
        add: nameInput;
        add: ageInput;
        add: okButton

PersonEditor >> initializeWindowAspects
    "Configure the dialog presenter's window properties"
"does not work with openModal"
    dialogPresenter
        title: self title;
        initialExtent: 300 @ 180

PersonEditor >> person
    "Answer the person being edited/created"
    ^ person

PersonEditor >> person: aPerson
    "Set the person and update the input fields"
    person := aPerson.
    nameInput text: person name.
    ageInput number: person age

PersonEditor >> ok
    "Called when OK button is pressed. Validate and apply changes."
    | name age |
    name := nameInput text trimBoth.
    age := ageInput number.

    (name isEmpty) ifTrue: [
        ^ UIManager default inform: 'Name cannot be empty.'.
    ].
    (age isNil or: [ age < 0 ]) ifTrue: [
         ^ UIManager default inform: 'Please enter a valid non-negative age.'.
    ].

    "Update the person object"
    person name: name.
    person age: age.

    "Close the dialog successfully"
    dialogPresenter accept

PersonEditor >> cancel
    "Called when Cancel button is pressed"
    dialogPresenter cancel

4. The Main Browser (PersonBrowser)

This is the main window showing the list and CRUD buttons.

Object << #PersonBrowser
    slots: { #listPresenter . #addButton . #editButton . #deleteButton . #selectedPerson . #mainPresenter . #windowPresenter};
    package: 'SpecCRUD-Example-UI-Composition'

Implement the main UI:

Class side:

PersonBrowser class >> open
    "Convenience method to open the browser"
    <script>
    ^ self new open

Instance side:

PersonBrowser >> initialize
    "Initialize the browser logic and create managed presenters"
    super initialize.
    self initializePresenters.
    self initializeLayout.
    self updatePersonList. "Load initial data"

PersonBrowser >> initializeLayout
    "Define the layout and set it on the managed main presenter"
    | layout |
    layout := SpBoxLayout newVertical
          add: listPresenter;
          add: (SpBoxLayout newHorizontal
                   add: addButton expand: false;
                   add: editButton expand: false;
                   add: deleteButton expand: false;
                   yourself)
              expand: false;
          yourself.

    mainPresenter layout: layout.

PersonBrowser >> initializePresenters
    "Create the main presenter and the UI components it will contain"
    mainPresenter := SpPresenter new. "The presenter holding the layout"

    listPresenter := SpListPresenter new.
    listPresenter display: [ :item | item displayString ].
    listPresenter whenSelectionChangedDo: [ :selection | self updateSelection: selection ].

    addButton := SpButtonPresenter new
                     label: 'Add';
                     icon: self getIconAdd;
                     action: [ self addPerson ].

    editButton := SpButtonPresenter new
                      label: 'Edit';
                      icon: self getIconEdit;
                      action: [ self editPerson ];
                      disable. "Initially disabled"

    deleteButton := SpButtonPresenter new
                        label: 'Delete';
                        icon: self getIconDelete;
                        action: [ self deletePerson ];
                        disable. "Initially disabled"

    self updatePersonList "Load initial data"

PersonBrowser >> updatePersonList
    "Refresh the list from the repository and maintain selection if possible"
    | previouslySelected |
    previouslySelected := selectedPerson.
    listPresenter items: PersonRepository current allPersons.

    "Try to re-select the previously selected item"
    (previouslySelected notNil and: [ listPresenter items includes: previouslySelected ])
        ifTrue: [ listPresenter selectItem: previouslySelected ]
        ifFalse: [ self updateSelection: listPresenter selection ] "Update button states even if selection cleared"

PersonBrowser >> updateSelection: aSelectionPresenter
    "Called when the list selection changes"
    selectedPerson := aSelectionPresenter selectedItem.
    editButton enabled: selectedPerson notNil.
    deleteButton enabled: selectedPerson notNil

PersonBrowser >> addPerson
    "Open the editor to add a new person"
    | newPerson |
    newPerson := PersonEditor openModalFor: nil title: 'Add New Person'.
    newPerson ifNotNil: [
        PersonRepository current addPerson: newPerson.
        self updatePersonList.
        listPresenter selectItem: newPerson "Select the newly added item"
    ]

PersonBrowser >> editPerson
    "Open the editor to edit the selected person"
    | editedPerson originalPerson |
    selectedPerson ifNil: [ ^ self ]. "Should not happen if button is enabled, but be safe"

    originalPerson := selectedPerson. "Keep track of the original instance"
    editedPerson := PersonEditor openModalFor: originalPerson title: 'Edit Person'.

    editedPerson ifNotNil: [
        "Update the original person object in the repository with the new data"
        PersonRepository current updatePerson: originalPerson with: editedPerson.
        self updatePersonList. "Refresh the list"
        listPresenter selectItem: originalPerson "Re-select the edited item (it's the same instance)"
    ]

PersonBrowser >> deletePerson
    "Delete the selected person after confirmation"
    | personToDelete |
    personToDelete := selectedPerson.
    personToDelete ifNil: [ ^ self ]. "Should not happen"

    (UIManager default confirm: 'Are you sure you want to delete ', personToDelete displayString, '?')
        ifTrue: [
            PersonRepository current removePerson: personToDelete.
            selectedPerson := nil. "Clear selection reference"
            self updatePersonList "Refresh the list"
        ]
			ifFalse: [ ^ nil ]
			
⚠️ **GitHub.com Fallback** ⚠️