TornadoFX Advanced TableView Editing

Yesterday I started working on an editable orderline grid for the TornadoFX version of our CRM system, which we are currently porting from HTML5. I ran across some issues with the default JavaFX TableView control, and I wanted to share these issues and their solution with you.

The experience spawned several new features, which will be in the TornadoFX 1.5.10 release scheduled for release this week.

The most interesting feature displayed is the type safe way to construct select bindings, or deeply nested, editable properties. This has a number of handy use cases, some of which is showcased in this screencast.

This example shows you how to create a writeable property for the Orderline -> Product -> Id property in the example data model:

class Orderline {
    val productProperty = SimpleObjectProperty<Product>()
    val productIdProperty = productProperty.select(Product::idProperty)
} 

You can use the productIdProperty as a completely normal Property<Int>. These type safe select bindings even let you swap out any property in the select chain and the resulting property will still work and do the right thing.

The code for the OrderlineEditor as shown in the video looks like this:

class OrderlinesEditor : Fragment() {
    val order: OrderModel by inject()
    val orderline: OrderlineModel by inject()

    val orderController: OrderController by inject()

    override val root = tableview(order.orderlines.value) {
        column("Product", Orderline::productId).fixedWidth(70)
        column("Text", Orderline::textProperty).remainingWidth().makeEditable()
        column("Price", Orderline::priceProperty).fixedWidth(70).makeEditable(AmountConverter).addClass(right)
        column("Price Inc", Orderline::priceIncProperty).fixedWidth(70).makeEditable(AmountConverter).addClass(right)
        column("Qty", Orderline::qtyProperty).fixedWidth(50).makeEditable(LocaleAwareBigDecimalStringConverter()).addClass(center)
        column("Vat", Orderline::vatFactorProperty).fixedWidth(50).makeEditable(LocaleAwareBigDecimalStringConverter()).addClass(right)
        column("Sum", Orderline::sum).converter(AmountConverter).fixedWidth(70).addClass(right, bold)
        column("Sum Inc", Orderline::sumInc).converter(AmountConverter).addClass(right, bold)

        enableCellEditing()
        regainFocusAfterEdit()
        bindSelected(orderline)

        onEditCommit {
            orderController.updateOrderline(it)
        }

        columnResizePolicy = SmartResize.POLICY
    }

}

You might wonder why the AmountConverter is not instantiated like the others? I just call converter(AmountConverter) instead of converter(AmountConverter() as you would expect. This converter has no shared state, so I didn't see a reason to create multiple versions of it. Therefor I elected to make it an object:

object AmountConverter : StringConverter<Amount>() {
    override fun toString(amount: Amount?) = amount?.toNorwegianString()
    override fun fromString(string: String?) = if (string == null) null else Amount.of(string)
}

TornadoFX runAsync with TaskStatus (screencast)

During yesterday's live hacking session it became apparent that TornadoFX was missing an important feature when executing long running tasks using the runAsync helper. In short, there was no way to access the underlying Task object, so you couldn't update the message and progress of your task. The solution was to manually implement the Task class and manage it the standard JavaFX way. We always take great care to not hide any parts of the JavaFX API, so this was relatively undramatic. The code in question looked like this:

val t = object : Task<Unit>() {
    override fun call() {
        updateMessage("Loading customers")
        updateProgress(0.4, 1.0)
        customerModel.loadCustomers()
    }

    override fun succeeded() {
        customers.set(customerModel.customers.observable())
    }
}

taskRunning.bind(t.runningProperty())
taskProgress.bind(t.progressProperty())
taskMessage.bind(t.messageProperty())

Thread(t).start()

The amount of boilerplate isn't staggering, and it's pretty easy to reason about what's going on, but not being able to use runAsync was painful, atleast for me. I've since changed the scope of runAsync so it has full access to the underlying Task object. I went one step further and introduced a TaskStatus helper to greatly reduce the boiler plate included to actually maintain and display the status of your task. The result can be seen in today's screencast:

The code you saw above the video was converted into this:

runAsync {
    updateMessage("Loading customers")
    updateProgress(0.4, 1.0)
    customerModel.loadCustomers()
} ui {
    customers.set(customerModel.customers.observable())
}

For completeness, the code for the whole application is provided below.

import javafx.beans.property.SimpleIntegerProperty
import javafx.beans.property.SimpleObjectProperty
import javafx.beans.property.SimpleStringProperty
import javafx.collections.ObservableList
import javafx.scene.Scene
import tornadofx.*
import javax.json.JsonObject

class Customer : JsonModel {
    val lastNameProperty = SimpleStringProperty()
    var lastName by lastNameProperty

    val firstNameProperty = SimpleStringProperty()
    var firstName by firstNameProperty

    val idProperty = SimpleIntegerProperty()
    var id by idProperty

    override fun toString() = "$firstName $lastName"

    override fun updateModel(json: JsonObject) = with(json) {
        id = getInt("id")
        firstName = getString("firstName")
        lastName = getString("lastName")
    }
}

class MainView : View("Customer App") {
    val status: TaskStatus by inject()

    override val root = vbox {
        splitpane {
            add(CustomerListFragment::class)
            add(CustomerDetailsFragment::class)
            paddingAll = 4
        }

        hbox(4.0) {
            progressbar(status.progress)
            label(status.message)
            visibleWhen { status.running }
            paddingAll = 4
        }
    }
}

class CustomerListFragment : Fragment() {
    val customerViewModel: CustomerViewModel by inject()

    override val root = vbox(4.0) {
        button("Refresh") {
            setOnAction { refresh() }
        }
        listview<Customer> {
            itemsProperty().bind(customerViewModel.customers)
            bindSelected(customerViewModel)
        }

        paddingAll = 10
    }

    fun refresh() {
        customerViewModel.refresh()
    }

}

class CustomerDetailsFragment : Fragment() {
    val customerViewModel: CustomerViewModel by inject()

    override val root = vbox {
        label("First Name")
        textfield(customerViewModel.selectedFirstName)
        label("Last Name")
        textfield(customerViewModel.selectedLastName)
        paddingAll = 10.0
    }
}

class CustomerViewModel : ItemViewModel<Customer>() {
    val customerModel: CustomerModel by inject()
    val customers = SimpleObjectProperty<ObservableList<Customer>>()
    val selectedFirstName = bind { item?.firstNameProperty }
    val selectedLastName = bind { item?.lastNameProperty }

    fun refresh() {
        runAsync {
            updateMessage("Loading customers")
            updateProgress(0.4, 1.0)
            customerModel.loadCustomers()
        } ui {
            customers.set(customerModel.customers.observable())
        }
    }
}

class CustomerModel : Controller() {
    val api: Rest by inject()
    val customers = mutableListOf<Customer>()

    fun loadCustomers() {
        customers.clear()
        customers.addAll(api.get("customers.json").list().toModel())
    }
}

class CustomerApp : App(MainView::class) {
    val api: Rest by inject()

    override fun createPrimaryScene(view: UIComponent) = Scene(view.root, 568.0, 320.0)

    init {
        api.baseURI = "https://www.bekwam.net/data"
    }
}

I hope you enjoyed this improvement for runAsync, I can't wait to share it with you in the upcoming TornadoFX 1.5.10 release!

Live hacking with TornadoFX

As we continue to work on the TornadoFX Guide it’s becoming clear that some things are hard to describe well in the documentation format. We’ve come up with a process where someone writes a small app and then I try to react live to that code with the purpose of explaining best practices or simply how to use more of the features of TornadoFX.

It’s important to note that there is no right way with TornadoFX. There is only your way. Many frameworks dictate a workflow for you, and you’re more or less forced to walk that exact path. This is often enforced in the form of interfaces you need to implement to access certain functionality. TornadoFX gives you more freedom to express yourself in your own way. Some like imperative style, others like to be more declarative.

Yesterday we did another React Video in this format, the result is below:

I talked to Carl after the session, and he explained the reason for the usage of events. The sample was built with the MVVM pattern in mind, and the events make sure it’s easy to split the classes into smaller parts as the functionality grows.

As a result of this “React Session” we found a way to make sure that `runAsync` has access to the `Task` so you can call `updateMessage()` and the other member functions in `Task`. This feature will be available in TornadoFX 1.5.10, due to be released next week.

My hope is that these videos will show you what our documentation can’t, and that it will help you to develop your own expression in your TornadoFX code.

Leveraging TornadoFX to the fullest


Notice: Trying to access array offset on value of type null in /var/www/0/138308/www/wp-content/plugins/markdown-shortcode/parsedown/ParsedownExtra.php on line 243

Notice: Trying to access array offset on value of type null in /var/www/0/138308/www/wp-content/plugins/markdown-shortcode/parsedown/ParsedownExtra.php on line 243

Carl Walker recently wrote an insightful post on TableView Binding in Kotlin where he uses TornadoFX to create a nice looking TableView with a couple of buttons that are disabled depending on the state of the currently selected item.

He also wrote the same program in plain Java and the post illustrates how TornadoFX can greatly reduce boiler plate code.

Coming from a strong JavaFX background, Carl's approach is very reasonable, but I'd like to show you that we can improve upon it even more by leveraging some more TornadoFX features.

TornadoFX is all about reducing boiler plate, while increasing readability and maintainability.

First I'll post the complete application as Carl created it, then I'll walk through each element I feel we can improve upon and finally post the full source for the modified app.

Here is the initial application without imports:

data class Item(val sku : String, val descr : String, val price : Float, val taxable : Boolean)

class TableSelectView : View("TableSelectApp") {

    private val items = FXCollections.observableArrayList(
        Item("KBD-0455892", "Mechanical Keyboard", 100.0f, true),
        Item("145256", "Product Docs", 0.0f, false),
        Item("OR-198975", "O-Ring (100)", 10.0f, true)
    )

    var tblItems : TableView<Item> by singleAssign()
    var btnInventory : Button by singleAssign()
    var btnCalcTax : Button by singleAssign()

    override val root = vbox {
        tblItems = tableview(items) {

            column("SKU", Item::sku)
            column("Item", Item::descr)
            column("Price", Item::price)
            column("Taxable", Item::taxable)

            prefWidth = 667.0
            prefHeight = 376.0

            columnResizePolicy = CONSTRAINED_RESIZE_POLICY

            vboxConstraints {
                vGrow = Priority.ALWAYS
            }
        }
        hbox {
            btnInventory = button("Inventory")
            btnCalcTax = button("Tax")

            spacing = 8.0
        }

        padding = Insets(10.0)
        spacing = 10.0
    }

    init {

        btnInventory.disableProperty().bind( tblItems.selectionModel.selectedItemProperty().isNull )

        btnCalcTax.disableProperty().bind(
                tblItems.selectionModel.selectedItemProperty().isNull().or(
                    Bindings.select<Boolean>(
                            tblItems.selectionModel.selectedItemProperty(),
                            "taxable"
                    ).isEqualTo( false )
                )
        )
    }
}

class TableSelectApp : App(TableSelectView::class)

Syntactic Sugar

We'll start with something simple and probably insignificant, but we want to attack every piece of boiler plate, so I'll mention it anyway.

In JavaFX, you're used to setting properties on objects as you create them. A typical example is the spacing property on VBox and HBox containers. The original sample does:

vbox {
    spacing = 8.0
}

The box builders take spacing as a parameter, so you can write vbox(8.0) or vbox(spacing = 8.0) if you prefer. OK, that was low hanging fruit, but bear with me.

Next up is actually a feature we added to TornadoFX, but that also has a shorthand alternative. Configuring the constraints inside a VBox can be done inside a vboxConstraints block, but when you only configure a single constraint you're better off just using this shorthand:

vgrow = Priority.ALWAYS

Let's tackle the last minor issue before we move on to bigger fish. Setting preferred width and height can be done in a single statement:

setPrefSize(667.0, 376.0)

Builder Encapsulation

I have a golden rule I always try to follow: Whenever possible, avoid references to other ui elements. This reduces coupling, but more importantly it means that you will configure a single ui element in just one place. It's almost always less code as well. The original sample uses the singleAssign delegate that makes sure we only assign a value to the variable once. This is the original code:

var tblItems : TableView<Item> by singleAssign()
var btnInventory : Button by singleAssign()
var btnCalcTax : Button by singleAssign()

When these objects are created inside the builders, they are assigned to these variables:

tblItems = tableview(items)
btnInventory = button("Inventory")
btnCalcTax = button("Tax")

Later, in the init block of the class, these variables are configured further. The buttons gets their disabledProperty bound, and the tblItems is references from these bindings. This is the major issue with this code IMO.

The items are declared in one place, instantiated another and configured a third place in the code. That's three different places to look for how each of these elements are treated. We can actually change all this so everything is done in one place - inside the builder expression that created them. We can get rid of those singleAssign variable declarations, and make the binding expression much more concise in the process.

The reason this is needed in the original sample, is that the binding expressions work on the selectedItem of the TableView. We want to avoid that alltogether, so before we can clean up these variables, let's create a ViewModel.

ViewModel

An ItemViewModel can wrap an instance of your domain object and gives you properties you can bind against which will stay valid even when the item it represents is changed. We want the view model to solve two issues for us:

  • It should indicate which Item is currently selected in the table
  • It should have a property for the taxable state of that item

The latter is used in the button bindings, so we need to expose that as a property we can bind to, and we need that binding to be the same even when the item changes.

The ItemViewModel can be defined like this:

class MyItemViewModel : ItemViewModel<Item>() {
    val taxable: BooleanProperty = bind { SimpleBooleanProperty(item?.taxable ?: false) }
}

The MyItemViewModel can contain an item of type Item in our case, and it has a BooleanProperty called taxable. This property will always reflect the state of the selected item, or null if false. Now this might look a bit verbose. That's because our domain object Item doesn't contain JavaFX properties. That's another thing I would probably change if I wrote this app from scratch, but we'll keep it, so you see how to bind against POJO properties. If Item contained JavaFX properties it would be much simpler:

val taxable = bind { item?.taxableProperty }

Since TornadoFX makes it so easy to create JavaFX properties, you should always do that when your domain objects are exposed to JavaFX Nodes. Now that we have our view model, we can inject it into the view:

val mySelectedItem: MyItemViewModel by inject()

Next, we can get rid of all the singleAssign statements, the variable assignments (tblItems = etc) and that whole init block at the bottom. Instead we will define the bindings directly inside the builders.

Inside the TableView builder we bind the selected state of the tableview to our view model:

bindSelected(mySelectedItem)

Now whenever the selection changes, the item inside our view model is updated, and the taxable property will reflect the state of the selected item. This gives us a chance to clean up the bindings.

View Model Usage

The inventory button should be disabled when there is no selection in the table view. Now we can define everything in one place, and also leverage the empty property you get for free with the ItemViewModel:

button("Inventory") {
    disableProperty().bind(mySelectedItem.empty)
}

It's much easier to reason about the intent of this code, as you can almost read it as an English expression, and it's defined in one place, not three.

We do the same for the tax button, but here something magical happens:

button("Tax") {
    disableProperty().bind(mySelectedItem.empty.or(mySelectedItem.taxable.not()))
}

Whoa?! Remember how this used to look? All though it makes my skin crawl, I'll recite it for you:

btnCalcTax.disableProperty().bind(
    tblItems.selectionModel.selectedItemProperty().isNull().or(
        Bindings.select<Boolean>(
            tblItems.selectionModel.selectedItemProperty(),
            "taxable"
        ).isEqualTo( false )
    )
)

It's almost hard to believe that these two pieces of code actually have the same effect. The first one "reads" something like "Selected item is empty or selected item is not taxable". This you can infer in a two second glance. I don't think you can say the same for the Bindings.select expression we had originally.

The real power here comes from binding the ItemViewModel to the TableView, and I'm sure you agree now that it was most definitely worth it.

Tying it all together

Below you'll find the modified code, as I feel it should be written using TornadoFX 1.5.9. I would probably make two other adjustments, but I wanted it to be as close to the original sample as possible. I would rename Item to Product or something similar to avoid the unfortunate name clash with ItemViewModel, which is a TornadoFX construct, but more importantly I would create the properties as real JavaFX properties, utilizing the property delegates of TornadoFX. That would clean up that swearing inside the ItemViewModel as I demonstrated.

Here is the modified code:

data class Item(val sku: String, val descr: String, val price: Float, val taxable: Boolean)

class MyItemViewModel : ItemViewModel<Item>() {
    val taxable: BooleanProperty = bind { SimpleBooleanProperty(item?.taxable ?: false) }
}

class TableSelectView : View("TableSelectApp") {

    private val items = FXCollections.observableArrayList(
            Item("KBD-0455892", "Mechanical Keyboard", 100.0f, true),
            Item("145256", "Product Docs", 0.0f, false),
            Item("OR-198975", "O-Ring (100)", 10.0f, true)
    )

    val mySelectedItem = MyItemViewModel()

    override val root = vbox(10.0) {
        tableview(items) {
            column("SKU", Item::sku)
            column("Item", Item::descr)
            column("Price", Item::price)
            column("Taxable", Item::taxable)
            bindSelected(mySelectedItem)
            setPrefSize(667.0, 376.0)
            columnResizePolicy = CONSTRAINED_RESIZE_POLICY
            vgrow = Priority.ALWAYS
        }
        hbox(8.0) {
            button("Inventory") {
                disableProperty().bind(mySelectedItem.empty)
            }
            button("Tax") {
                disableProperty().bind(mySelectedItem.empty.or(mySelectedItem.taxable.not()))
            }
        }

        padding = Insets(10.0)
    }

}

Conclusion

TornadoFX has a lot of features to simplify your UI code. You most certainly don't need to use them all, just use whatever portion you feel comfortable with, but know that if you ever write boiler plate in a TornadoFX app, you're probably not following best practices or we're missing something in the framework 🙂

As the syntax and features have progressed so much during 2016, we have a lot of dated samples out there. We'll try to clean up as many as possible over the coming weeks, but the TornadoFX Guide is in pretty good shape already, and is one of the best resources to help you get started.

Pizza


Notice: Trying to access array offset on value of type null in /var/www/0/138308/www/wp-content/plugins/markdown-shortcode/parsedown/ParsedownExtra.php on line 243

Notice: Trying to access array offset on value of type null in /var/www/0/138308/www/wp-content/plugins/markdown-shortcode/parsedown/ParsedownExtra.php on line 243

Pizza

På denne siden har jeg skrevet ned min oppfatning av hva som er viktig for å lage god pizza. Påstandene her er min forståelse akkurat nå, og vil antakelig forandres etterhvert som jeg lærer mer. Work in progress!

Her følger oppskriften jeg bruker akkurat nå, etterfulgt av trinn for heving, utpakking, baking og viktig informasjon om ingrediensene.

Pizzadeig

Nok til to italienske porsjonspizzaer.

Ingredienser

  • 5 dl tipo 00 pizzamel (ca 320g)
  • 7g salt
  • 7g sukker
  • 7g fersk gjær
  • 2 ss god extra virgin olivenolje
  • 2 dl lunkent vann (38 grader)
  • Litt durum hvete (semulato grano duro) til utpakking

Dette tilsvarer ca 2% salt, sukker og gjær og 63% vann i forhold til mel (bakers percentage). Du kan eksperimentere med litt mer vann, opp mot 70%.

Lage deig

Bland vann, sukker og gjær og miks til gjæren løser seg opp. La blandingen svelle i ca 10 minutter til du ser at gjæren aktiveres.

Bland mel og vannmiksen sammen og elte litt før du tilfører salt. Når saltet har blandet seg tilfører du oljen. Elte til en jevn og litt klissen masse.

Deigballer

Ha deigen ut på benken, det er ikke nødvendig å bruke mel. Håndkna deigen til den samler seg godt, og del i to like store deigballer. Disse formes til helt runde og jevne boller. Knip sammen eventuelle hull på undersiden. Legg ballene på et stekebrett smurt med litt olivenolje.

Heving

Sett brettet direkte i kjøleskapet uten å dekke det til i ca. 30 minutter. Dette sørger for at luften kommer til å kjøler ned deigballene så raskt som mulig. Ha deretter over plastfolie. Istedenfor stekebrett og folie kan du bruke en litt romslig tupperware boks eller investere i noen skikkelige proofing pans. La deigballene heve i minst 24 timer, gjerne 48.

Utpakking

Ta ut deigballene fra kjøleskapet og la de få romtemperatur før du begynner. Dette tar gjerne en times tid.

Mel bordet med durum hvete (semulato grano duro). Dette er langt grovere enn tipo 00, og er med på å gi mer struktur til skorpen, i tillegg til at det tyngre og ikke griser til kjøkkenet så fælt.

Når du skal pakke ut en pizza henter du opp deigballen ved hjelp av en pizzaskrape eller en stekespade i plast. Legg deigballen ned i melet og gni mel over den så du får vekk all fuktighet. Snu og gjenta på undersiden før du flipper den tilbake igjen. Siden som lå ned skal fortsatt være ned før du går videre.

Resten av prosessen er vanskelig å beskrive på noen meningfull måte med ord, men du finner mange fine videoer om utpakking av pizza på YouTube.

Steking

Regel nummer 1 er å steke på så høy temperatur som mulig. Pizzaen må settes inn når ovenen er på det varmeste, og du må unngå å åpne døren unødvendig under stekingen. Hvis du bare har en vanlig ovn må du skaffe deg en pizzastein eller et pizzastål til å legge i bunnen. Alternativt kan du bruke teglstegn som du legger i bunnen av ovnen din. Tanken er å sette inn pizzaen direkte på den glovarme bunnen, slik at den stekes ovenfra og nedenfra samtidig, med et brutalt angrep av varme. For å få til dette må du sette inn pizzaen med en pizzaspade. Spader i forskjellige materialer har ulike egenskaper, mulig jeg utdyper litt om dette senere.

Viktigheten av de ulike ingrediensene

Salt

Salt setter smak på pizzadeigen. Uten salt, eller med for lite salt vil pizzaen smake kjedelig, uansett hvor mye topping du har på. Men saltet har en annen egenskap som er enda viktigere for en god pizzadeig: Den bidrar til elastisitet så deigen blir sterkere og mindre klissete.

Saltet bidrar til å styrke gluten-nettet i deigen. Denne oppskriften har mer salt enn du kanskje er vant til, men deigen blir også fantastisk mye lettere å håndstrekke og jobbe med.

Salt tiltrekker seg fuktighet. Derfor vil en pizzadeig med salt holde lenger på vannet etter steking slik at den ikke vil bli så fort tørr som en pizzadeig med for lite salt. Salt bør utgjøre mellom 1,7% og 2,1% av melvekten.

Sukker

Mange pizzaoppskrifter har ikke sukker, og mange ganger er det ikke nødvendig. Gjær spiser sukker, men det er allerede naturlig sukker i melet, så gjæren har ofte nok å spise som det er.

I tillegg til sette smak og påvirke struktur, har sukkeret en annen viktig egenskap: Den bidrar til å brune pizzadeigen under steking. Avhengig av temperaturen på oven din kan du leke med sukkermengden (eller kutte den helt) for å påvirke bruningen i forhold til stekingen. I vedfyrte pizzaovner er det ofte ikke nødvendig med sukker. Et mer spennende alternativ er malt, som også bidrar til bruning.

Gjær

Gjær er en levende organisme som spiser sukker. Aktiv gjær konverterer karbohydrater til karbondioksid og alkoholer. Dette er årsaken til at deigen vår hever, men minst like viktig er smakene som utvikler seg underveis i prosessen. Det lenger deigen hever, det mer kompleks smak får den. For å unngå at deigen poffer og blir ødelagt må man redusere temperaturen for å senke hastigheten på heveprosessen. Det er derfor vi kaldhever. Det gir gjæren tid til å utvikle kompleks smak i deigen uten å ødelegge den.

Ung deig er i tillegg mye hardere for magen. En pizzadeig som har fått kaldheve i minst 24 timer vil gi deg en mye bedre magefølelse etter spising.

Mel

Ekte italiensk pizza må lages med tipo 00 mel, proteinrikt og fullt av gluten, malt på en meget fin grovhetsgrad. Jeg har god erfaring med Caputo or Dallari. For utpakking trenger du litt durum hvete, se mer informasjon under.

Tomater

Hvis du vil lage din egen pizzasaus anbefaler jeg å bruke San Marzano tomater. Du trenger ikke å koke sausen, bare tilsett litt olje, salt og ferske basilikumblader. Blend raskt med en stavmikser og la sausen sette seg noen timer, så er den klar til bruk. Enkelt, stilrent og fantastisk godt. Hvis du ikke vil lage egen saus kan du kjøpe Mutti Aromatizzata.

Mange liker å tilføre litt sukker i tomatsausen også. Enkelte restaurantkjeder gjør det med stort hell. Prøv deg frem for å finne din favorittsaus!

Olivenolje

Oljen setter smak og gjør deigen smidig. Litt olje på toppen av pizzen før den går i ovnen tilfører en rik og litt kompleks smak. Men pass på: Oljen omkapsler deigen og kan gjøre det vanskelig for fuktigheten i å trenge inn i melet. Derfor tilføres oljen etter at de andre ingrediensene er blandet inn. For dessertpizzaer og spesielle prosjekter kan det være at oljesmaken ikke passer, da kan du trygt utelate den.

Romtemperatur

Alle ingredienser som skal på pizzaen må ha romtemperatur før du bruker de.