This is an example application to show and explain features of QOR.
You need basic understanding of Go to understand the documentation of this app.
Run the code from the QOR repository in
cd $GOPATH/src/github.com/qor/qor-example
Once you have gone through this doc you could copy the directory structure of the app to use it as a template or start your own application from scratch.
We want to create a simple bookstore application. We will start by building a catalog of books and then later add a storefront. We will add a staging environment (database rather) so that editors can make changes to contents and then later publish them to a live database.
We will add Localization (L10n) for the books and authors and Internationalization (I18n) support for the complete back-office we built with qor/admin
.
-
GoLang 1.x+ (at the time of writing I am using >=1.4.0 versions)
-
Install QOR and its dependencies:
go get -u github.com/qor/qor cd $GOPATH/src/github.com/qor/qor go get ./...
-
Install the example app itself
go get -u github.com/qor/qor-example
-
A database - for example PostgreSQL or MySQL
-
Install Gin - QOR does not require gin, but we use it in the example application for routing and templating:
go get github.com/gin-gonic/gin
-
Optional: fresh being installed:
go get github.com/pilu/fresh
fresh
is not necessary to use QOR, but it will make your life easier when playing with the tutorial: It monitors for file changes and automatically recompiles your code every time something has changed.
If you don't want to go with fresh you will have to terminate, rebuild, and rerun your code every time instead.
Before we dive into our models we need to create a database. The example app uses Postgres by default - if you prefer MySQL run with application with DB=mysql
environment, like:
DB=mysql go run main.go
sudo su - postgres
postgres@lain:~$ psql
psql (9.4.4)
Type "help" for help.
postgres=# create database qor_bookstore;
CREATE DATABASE
postgres=# \c template1
You are now connected to database "template1" as user "postgres".
template1=# CREATE USER qor WITH PASSWORD 'qor';
template1=# GRANT all ON DATABASE qor_bookstore TO qor;
$ mysql -uroot -p
mysql> DROP DATABASE IF EXISTS qor_bookstore;
mysql> CREATE DATABASE qor_bookstore DEFAULT CHARACTER SET utf8mb4;
mysql> CREATE USER 'qor'@'localhost' IDENTIFIED BY 'qor';
mysql> GRANT ALL ON qor_bookstore.* TO 'qor'@'localhost';
mysql> FLUSH PRIVILEGES;
We will need the following two models to start with:
- Author
- Book
The Author
model is very simple:
type Author struct {
gorm.Model
publish.Status
l10n.Locale
Name string
}
All QOR models "inherit" from gorm.model
. (see https://github.com/jinzhu/gorm).
Our author model for now only has a Name
.
Ignore publish.Status
and l10n.Locale
for now - we will address these in later parts of the tutorial.
The Book model has a few more fields:
type Book struct {
gorm.Model
publish.Status
l10n.Locale
Title string
Synopsis string
ReleaseDate time.Time
Authors []*Author `gorm:"many2many:book_authors"`
Price float64
CoverImage media_library.FileSystem
}
The only interesting part here is the Gorm struct tag: gorm:many2many:book_authors"
; It tells gorm
to create a join table book_authors
.
Ignore publish.Status
, l10n.Locale
and media_library.FileSystem
for now - we will address these in later parts of the tutorial.
That's almost it: If you look at models.go you can see an init()
function at the end: It sets up a db connection and db.AutoMigrate(&Author{}, &Book{}, &User{})
tells QOR to automatically create the tables for our models.
You can ignore the User model for now - we will look at that part later.
Let's start the tutorial app once to see what happens when models get auto-migrated.
cd $GOPATH/src/github.com/qor/qor-example
go/src/github.com/qor/qor-example [01 (master)] $ fresh
If you don't want to use fresh you can build and run the app:
/go/src/github.com/qor/qor-example [01 (master)] $ go build -o tutorial main.go
/go/src/github.com/qor/qor-example [01 (master)] $ ./tutorial
If you now check your db you would see something like this:
qor_bookstore=# \d
List of relations
Schema | Name | Type | Owner
--------+----------------------+----------+-------
public | authors | table | qor
public | authors_draft | table | qor
public | authors_draft_id_seq | sequence | qor
public | authors_id_seq | sequence | qor
public | book_authors | table | qor
public | books | table | qor
public | books_draft | table | qor
public | books_draft_id_seq | sequence | qor
public | books_id_seq | sequence | qor
public | translations | table | qor
public | users | table | qor
public | users_id_seq | sequence | qor
(12 rows)
qor_bookstore=# \d authors
Table "public.authors"
Column | Type | Modifiers
----------------+--------------------------+------------------------------------------------------
id | integer | not null default nextval('authors_id_seq'::regclass)
created_at | timestamp with time zone |
updated_at | timestamp with time zone |
deleted_at | timestamp with time zone |
publish_status | boolean |
language_code | character varying(6) | not null
name | character varying(255) |
Indexes:
"authors_pkey" PRIMARY KEY, btree (id, language_code)
mysql> show tables;
+-------------------------+
| Tables_in_qor_bookstore |
+-------------------------+
| authors |
| authors_draft |
| book_authors |
| book_authors_draft |
| books |
| books_draft |
| translations |
| users |
+-------------------------+
8 rows in set (0.00 sec)
mysql> describe authors;
+----------------+------------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+----------------+------------------+------+-----+---------+----------------+
| id | int(10) unsigned | NO | PRI | NULL | auto_increment |
| created_at | timestamp | YES | | NULL | |
| updated_at | timestamp | YES | | NULL | |
| deleted_at | timestamp | YES | | NULL | |
| publish_status | tinyint(1) | YES | | NULL | |
| language_code | varchar(6) | NO | PRI | | |
| name | varchar(255) | YES | | NULL | |
+----------------+------------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)
As you can see QOR/Gorm added an id
field as well as timestamp fields to keep track of creation, modification, and deletion times. We can ignore this for now - the main point is that you create your models without a unique identifier - QOR/Gorm will do this for you automatically.
NB: If you add new fields to your model they will get added to the database automatically with DB.AutoMigrate
- deletions or changes of eg. the type will not be automatically migrated.
In the next step we want to log into the admin so we need some users. Run this on your database:
INSERT INTO users (name,role) VALUES ('admin','admin');
INSERT INTO users (name,role) VALUES ('user1','user');
You must have run the application once before this step, otherwise the users table would not yet exist.
Before we look at the actual admin here is a brief breakdown of the directory structure of the example app:
.
├── app
│ ├── controllers
│ │ └── controllers.go
│ ├── models
│ │ └── models.go
│ └── resources
│ └── resources.go
├── main.go
├── public
│ ├── assets
│ │ └── css
│ │ └── bookstore.css
│ └── system
└── templates
├── book.tmpl
└── list.tmpl
- The controllers are in
app/controllers
- models and db initialization happen in
app/models
- Resources are an integral part of QOR/admin. Whenever resources or
Meta...
is mentioned in this doc you will find the code it's referring to inapp/resources
- main.go starts the webserver and additionally contains the routes right now. In a bigger project you would put them probably somewhere like
app/config/routes.go
- Static files are served from
public
.public/system
is where theqor/medialibrary
puts files related to your resources - eg. an uploaded image
If the bookstore app is not yet running, start it by running fresh
in the bookstore directory:
go/src/github.com/qor/qor-example [bookstore (master)] $ fresh
Go to http://localhost:9000/admin and log in as admin
. You should see the main admin interface:
The menu at the top gets created by adding your models as resources to the admin in main.go:
Admin := admin.New(&qor.Config{DB: &db})
Admin.AddResource(
&User{},
&admin.Config{
Menu: []string{"User Management"},
Name: "Users",
},
)
You can see how the rest of the resources were added in resources.go, the db
object referenced here is set up in models.go
Go ahead and go to the authors admin and add an author...
... and then a book via the admin:
Go to http://localhost:9000/admin/books. Look for the following line from resources.go
book.IndexAttrs("ID", "Title", "Authors", "FormattedDate", "Price")
and change FormattedDate
to ReleaseDate
We are getting the value of the Book.ReleaseDate
. We want to show the date only so we define a Meta
field:
book.Meta(&admin.Meta{
Name: "FormattedDate",
Label: "Release Date",
Valuer: func(value interface{}, context *qor.Context) interface{} {
book := value.(*Book)
return book.ReleaseDate.Format("Jan 2, 2006")
},
})
We define a Meta
field for the book
Resource
. It's internal name is "FormattedDate", which we use in book.IndexAttrs()
to use it in our admin book listing. The Label
is what goes into the table header and the Valuer
is a function that will return the display value we want - in our case the formatted date that does not include the time.
By default all defined model attributes and Meta
attributes are included in the edit interface. If you need to limit the fields that are editable you can call EditAttrs
(for editing existing objects) and NewAttrs
(for object creation):
book.NewAttrs("Title", "Authors", "Synopsis", "ReleaseDate", "Price", "CoverImage")
book.EditAttrs("Title", "Authors", "Synopsis", "ReleaseDate", "Price", "CoverImage")
To get a searchfield on the list display of your resource you simply add a line like this:
book.SearchAttrs("ID", "Title")
Which will add a search(field) for resources matching on the defined fields.
QOR will pick an input type based on your struct types - but sometimes you want to change the default. For example we might want to have a text area with some editing functions instead of just an <input type="text">
:
book.Meta(&admin.Meta{
Name: "Synopsis",
Type: "rich_editor",
})
TODO: other types - at least select_one and select_many.
import "github.com/qor/qor/publish"
The Publish module allows you edit contents of your site without having them go online right away. Every model that you want to be able to Publish
needs to inherit publish.Status
:
type Author struct {
gorm.Model
publish.Status
l10n.Locale
Name string
}
Then initialize Publish
and set up AutoMigrate (see init() in models.go):
Pub = publish.New(&Db)
Pub.AutoMigrate(&Author{}, &Book{})
StagingDB = Pub.DraftDB() // Draft resources are saved here
ProductionDB = Pub.ProductionDB() // Published resources are saved here
And change the DB
config of admin
:
// Admin := admin.New(&qor.Config{DB: &db}) // this is without publish
Admin = admin.New(&qor.Config{DB: Pub.DraftDB()}) // with publish
Admin.AddResource(Pub)
You now have a Publish menu: Changes you make on publishable objects do not appear in the front end until they are published. Add an author and/or a book and check out the Publish section:
You can select the changes you want to publish (check out the "View Diff" link to see changes) and then either publish them to the live DB or discard them.
You can check that before publishing the first time your authors
table should be empty, while authors_draft
contains the contents you see in the QOR admin. After publishing these contents get copied to the live authors
table.
import "github.com/qor/qor/media_library"
We will only briefly touch on the qor/media_library
. It provides support for uploading, storage, and resizing of images. Define an attribute with the media_library.FileSystem
type:
type Book struct {
gorm.Model
publish.Status
l10n.Locale
[...]
CoverImage media_library.FileSystem
}
and you're almost done. You need a route to serve the files from:
router.StaticFS("/system/", http.Dir("public/system")) # this is in main.go
The public/system
directories must already exist - they do not get created by QOR.
Support for publish (draft version, publish to live) is built in. This is what the directory structure for the Book
CoverImage
s looks like:
/public [public (docs_and_tutorial)] $ tree
.
└── system
├── books
│ └── 1
│ └── CoverImage
│ ├── P1210896.20150604163815084702067.jpg
│ └── P1210896.20150604163815084702067.original.jpg
└── books_draft
└── 1
└── CoverImage
├── P1210896.20150604163815084702067.jpg
└── P1210896.20150604163815084702067.original.jpg
The directories for your resources (like books and books_draft) are created by the media_library. In your templates you can use the image like this:
<img src="{{.book.CoverImage}}" />
Edit the book you previously created and click on the image you uploaded there. The crop interface will appear:
To localize your resources, for example to have an English and a Japanese "version" of an author or a book you need to use the l10n
module.
import "github.com/qor/qor/l10n"
Any model you want to have localization support on needs to inherit from l10n.Locale:
type Author struct {
gorm.Model
publish.Status
l10n.Locale
Name string
}
Set your default locale (In the example app these are called at the end of the init()
function in app/models/models.go):
func init() {
l10n.Global = "en-US"
l10n.RegisterCallbacks(&Db)
}
l10n.Global = "en-US"
is the default used by the l10n
package, but better to be explicit here.
l10n.RegisterCallbacks
registers callbacks with gorm
to keep track of changes in the different locales.
The last step is to define who can view or edit which locales. In models.go
we have two methods defined on our User
type:
func (User) ViewableLocales() []string {
return []string{l10n.Global, "ja-JP"}
}
And to make the different locales viewable and
func (user User) EditableLocales() []string {
if user.Role == "admin" {
return []string{l10n.Global, "ja-JP"}
} else {
return []string{}
}
}
to make the different locales editable by the admin
role.
import "github.com/qor/qor/i18n"
To add I18n support for qor/admin
you need to register an i18n.I18n
resource:
var (
Admin *admin.Admin
I18n *i18n.I18n // this needs to be exported
)
func init() {
// setting up QOR admin
Admin = admin.New(&qor.Config{DB: Pub.DraftDB()})
[...]
I18n := i18n.New(database.New(StagingDB))
Admin.AddResource(I18n)
[...]
You can find the code in app/resources/resources.go
This will give you the I18n
menu entry in admin.
Go ahead and look for the translation key qor_admin.I18n
and translate it:
Set the English translation to Translations
, use the target language switcher in the table header and change the target language to ja-JP
(Japanese) and translate it to 翻訳
. Now reload admin and you will see your translation in the menu on the left.
If you go to eg. Authors and set the locale to ja-JP
you will see your translation appear in the admin menu on the left:
NB: Currently the example app is set up in a way that translation keys are added to the database the first time they are read from templates. In order to see all the keys you have to access each page where they are used once.
TODO: Add an example on how to import keys from eg. a YAML file.
QOR does not provide any built-in templating or routing support - you can use whatever library best fits your needs. In this example application tutorial we use gin:
In main.go
// frontend routes
router := gin.Default()
router.LoadHTMLGlob("templates/*")
we initialize a gin.Router
and tell it where it can find our templates.
// serve static files
router.StaticFS("/system/", http.Dir("public/system"))
router.StaticFS("/assets/", http.Dir("public/assets"))
add routes for static files
// books
bookRoutes := router.Group("/books")
{
// listing
bookRoutes.GET("", controllers.ListBooksHandler)
// single book - product page
bookRoutes.GET("/:id", controllers.ViewBookHandler)
}
and add two endpoints - one to list all our books and one book details page.
The controllers are defined in app/controllers/controllers.go:
func ListBooksHandler(ctx *gin.Context) {
# get the books data
[...]
ctx.HTML(
http.StatusOK,
"list.tmpl",
gin.H{
"books": books,
"t": func(key string, args ...interface{}) template.HTML {
return template.HTML(resources.I18n.T(retrieveLocale(ctx), key, args...))
},
},
)
}
func ViewBookHandler(ctx *gin.Context) {
[...]
}
We get the data for all or one book and then pass it to the template in ctx.HTML()
. One thing to note here is that we pass not only the data ( "books": books
) but also a function t
which is the translation function. It's used in the templates like this:
<h1>{{call .t "frontend.books.List of Books"}}</h1>
frontend.books.List of Books
will become the key that will appear in your translations resource. (after you accessed it once. See I18n - the NB at the end of the section if you don't know why).
Go ahead and point your browser to:
If you have books in your system but see an empty page you have most likely not yet published your data. Go to Publish section, select all items and hit publish:
http://localhost:9000/books should now show your books.
TODO: switching the language/locale on the frontend
This example app will be extended to eventually showcase most QOR features and a tutorial that goes through building this app step by step is planned too.
Go ahead and copy the example application and start using your own resources. Have fun with QOR!