Add UserList in UI

This commit is contained in:
Deluan 2020-01-19 20:40:18 -05:00
parent 3a03284c59
commit 1c04a19910
13 changed files with 282 additions and 23 deletions

2
go.mod
View file

@ -7,6 +7,7 @@ require (
github.com/Masterminds/squirrel v1.1.0
github.com/astaxie/beego v1.12.0
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131
github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a // indirect
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
@ -14,6 +15,7 @@ require (
github.com/fatih/structs v1.0.0 // indirect
github.com/go-chi/chi v4.0.3+incompatible
github.com/go-chi/cors v1.0.0
github.com/google/uuid v1.1.1 // indirect
github.com/google/wire v0.4.0
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a

4
go.sum
View file

@ -22,6 +22,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131 h1:siEGb+iB1Ea75U7BnkYVSqSRzE6QHlXCbqEXenxRmhQ=
github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131/go.mod h1:eVWQJVQ67aMvYhpkDwaH2Goy2vo6v8JCMfGXfQ9sPtw=
github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a h1:7MucP9rMAsQRcRE1sGpvMZoTxFYZlDmfDvCH+z7H+90=
@ -54,6 +56,8 @@ github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8l
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.4.0 h1:kXcsA/rIGzJImVqPdhfnr6q0xsS9gU0515q1EPpJ9fE=
github.com/google/wire v0.4.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=

View file

@ -2,6 +2,8 @@ package model
import (
"errors"
"github.com/deluan/rest"
)
var (
@ -22,6 +24,11 @@ type QueryOptions struct {
Filters Filters
}
type ResourceRepository interface {
rest.Repository
rest.Persistable
}
type DataStore interface {
Album() AlbumRepository
Artist() ArtistRepository
@ -31,5 +38,7 @@ type DataStore interface {
Playlist() PlaylistRepository
Property() PropertyRepository
Resource(model interface{}) ResourceRepository
WithTx(func(tx DataStore) error) error
}

View file

@ -3,9 +3,12 @@ package model
import "time"
type User struct {
ID string
Name string
Password string
IsAdmin bool
CreatedAt time.Time
ID string
Name string
Password string
IsAdmin bool
LastLoginAt time.Time
LastAccessAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}

View file

@ -52,3 +52,7 @@ func (db *MockDataStore) Property() model.PropertyRepository {
func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
return block(db)
}
func (db *MockDataStore) Resource(m interface{}) model.ResourceRepository {
return struct{ model.ResourceRepository }{}
}

View file

@ -16,8 +16,9 @@ import (
const batchSize = 100
var (
once sync.Once
driver = "sqlite3"
once sync.Once
driver = "sqlite3"
mappedModels map[interface{}]interface{}
)
type SQLStore struct {
@ -30,11 +31,12 @@ func New() model.DataStore {
if dbPath == ":memory:" {
dbPath = "file::memory:?cache=shared"
}
log.Debug("Opening DB from: "+dbPath, "driver", driver)
err := initORM(dbPath)
if err != nil {
panic(err)
}
log.Debug("Opening DB from: "+dbPath, "driver", driver)
})
return &SQLStore{}
}
@ -67,6 +69,10 @@ func (db *SQLStore) Property() model.PropertyRepository {
return NewPropertyRepository(db.getOrmer())
}
func (db *SQLStore) Resource(model interface{}) model.ResourceRepository {
return NewResource(db.getOrmer(), model, mappedModels[model])
}
func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
o := orm.NewOrm()
err := o.Begin()
@ -102,13 +108,6 @@ func (db *SQLStore) getOrmer() orm.Ormer {
func initORM(dbPath string) error {
verbose := conf.Sonic.LogLevel == "trace"
orm.Debug = verbose
orm.RegisterModel(new(artist))
orm.RegisterModel(new(album))
orm.RegisterModel(new(mediaFile))
orm.RegisterModel(new(checksum))
orm.RegisterModel(new(property))
orm.RegisterModel(new(playlist))
orm.RegisterModel(new(Search))
if strings.Contains(dbPath, "postgres") {
driver = "postgres"
}
@ -129,3 +128,22 @@ func collectField(collection interface{}, getValue func(item interface{}) string
return result
}
func registerModel(model interface{}, mappedModel interface{}) {
mappedModels[model] = mappedModel
orm.RegisterModel(mappedModel)
}
func init() {
mappedModels = map[interface{}]interface{}{}
registerModel(new(model.Artist), new(artist))
registerModel(new(model.Album), new(album))
registerModel(new(model.MediaFile), new(mediaFile))
registerModel(new(model.Property), new(property))
registerModel(new(model.Playlist), new(playlist))
registerModel(model.User{}, new(user))
orm.RegisterModel(new(checksum))
orm.RegisterModel(new(search))
}

View file

@ -0,0 +1,89 @@
package persistence
import (
"reflect"
"strings"
"github.com/astaxie/beego/orm"
"github.com/cloudsonic/sonic-server/model"
"github.com/deluan/rest"
)
type resourceRepository struct {
model.ResourceRepository
model interface{}
mappedModel interface{}
ormer orm.Ormer
instanceType reflect.Type
sliceType reflect.Type
}
func NewResource(o orm.Ormer, model interface{}, mappedModel interface{}) model.ResourceRepository {
r := &resourceRepository{model: model, mappedModel: mappedModel, ormer: o}
r.instanceType = reflect.TypeOf(mappedModel)
r.sliceType = reflect.SliceOf(r.instanceType)
return r
}
func (r *resourceRepository) newQuery() orm.QuerySeter {
return r.ormer.QueryTable(r.mappedModel)
}
func (r *resourceRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
qs := r.newQuery()
qs = r.addOptions(qs, options)
//qs = r.addFilters(qs, r.buildFilters(qs, options), r.getRestriction())
dataSet := r.NewSlice()
_, err := qs.All(dataSet)
if err == orm.ErrNoRows {
return dataSet, rest.ErrNotFound
}
return dataSet, err
}
func (r *resourceRepository) Count(options ...rest.QueryOptions) (int64, error) {
qs := r.newQuery()
//qs = r.addFilters(qs, r.buildFilters(qs, options), r.getRestriction())
count, err := qs.Count()
if err == orm.ErrNoRows {
err = rest.ErrNotFound
}
return count, err
}
func (r *resourceRepository) NewSlice() interface{} {
slice := reflect.MakeSlice(r.sliceType, 0, 0)
x := reflect.New(slice.Type())
x.Elem().Set(slice)
return x.Interface()
}
func (r *resourceRepository) addOptions(qs orm.QuerySeter, options []rest.QueryOptions) orm.QuerySeter {
if len(options) == 0 {
return qs
}
opt := options[0]
sort := strings.Split(opt.Sort, ",")
reverse := strings.ToLower(opt.Order) == "desc"
for i, s := range sort {
s = strings.TrimSpace(s)
if reverse {
if s[0] == '-' {
s = strings.TrimPrefix(s, "-")
} else {
s = "-" + s
}
}
sort[i] = strings.Replace(s, ".", "__", -1)
}
if opt.Sort != "" {
qs = qs.OrderBy(sort...)
}
if opt.Max > 0 {
qs = qs.Limit(opt.Max)
}
if opt.Offset > 0 {
qs = qs.Offset(opt.Offset)
}
return qs
}

View file

@ -9,7 +9,7 @@ import (
"github.com/kennygrant/sanitize"
)
type Search struct {
type search struct {
ID string `orm:"pk;column(id)"`
Table string `orm:"index"`
FullText string `orm:"index"`
@ -55,13 +55,13 @@ func (r *searchableRepository) purgeInactive(activeList interface{}, getId func(
}
func (r *searchableRepository) addToIndex(table, id, text string) error {
item := Search{ID: id, Table: table}
item := search{ID: id, Table: table}
err := r.ormer.Read(&item)
if err != nil && err != orm.ErrNoRows {
return err
}
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
item = Search{ID: id, Table: table, FullText: sanitizedText}
item = search{ID: id, Table: table, FullText: sanitizedText}
if err == orm.ErrNoRows {
err = r.insert(&item)
} else {
@ -79,7 +79,7 @@ func (r *searchableRepository) removeFromIndex(table string, ids []string) error
}
log.Trace("Deleting searchable items", "table", table, "num", len(subset), "from", offset)
offset += len(subset)
_, err := r.ormer.QueryTable(&Search{}).Filter("table", table).Filter("id__in", subset).Delete()
_, err := r.ormer.QueryTable(&search{}).Filter("table", table).Filter("id__in", subset).Delete()
if err != nil {
return err
}
@ -88,7 +88,7 @@ func (r *searchableRepository) removeFromIndex(table string, ids []string) error
}
func (r *searchableRepository) removeAllFromIndex(o orm.Ormer, table string) error {
_, err := o.QueryTable(&Search{}).Filter("table", table).Delete()
_, err := o.QueryTable(&search{}).Filter("table", table).Delete()
return err
}

View file

@ -0,0 +1,20 @@
package persistence
import (
"time"
"github.com/cloudsonic/sonic-server/model"
)
type user struct {
ID string `json:"id" orm:"pk;column(id)"`
Name string `json:"name" orm:"index"`
Password string `json:"-"`
IsAdmin bool `json:"isAdmin"`
LastLoginAt time.Time `json:"lastLoginAt"`
LastAccessAt time.Time `json:"lastAccessAt"`
CreatedAt time.Time `json:"createdAt" orm:"auto_now_add;type(datetime)"`
UpdatedAt time.Time `json:"updatedAt" orm:"auto_now;type(datetime)"`
}
var _ = model.User(user{})

View file

@ -1,10 +1,14 @@
package app
import (
"context"
"net/http"
"net/url"
"strings"
"github.com/cloudsonic/sonic-server/model"
"github.com/cloudsonic/sonic-server/server"
"github.com/deluan/rest"
"github.com/go-chi/chi"
)
@ -26,8 +30,54 @@ func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (app *Router) routes() http.Handler {
r := chi.NewRouter()
// Serve UI app assets
server.FileServer(r, app.path, "/", http.Dir("ui/build"))
// Basic unauthenticated ping
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"pong"}`)) })
r.Route("/api", func(r chi.Router) {
// Add User resource
R(r, "/user", func(ctx context.Context) rest.Repository {
return app.ds.Resource(model.User{})
})
})
return r
}
func R(r chi.Router, pathPrefix string, newRepository rest.RepositoryConstructor) {
r.Route(pathPrefix, func(r chi.Router) {
r.Get("/", rest.GetAll(newRepository))
r.Post("/", rest.Post(newRepository))
r.Route("/{id:[0-9a-f\\-]+}", func(r chi.Router) {
r.Use(UrlParams)
r.Get("/", rest.Get(newRepository))
r.Put("/", rest.Put(newRepository))
r.Delete("/", rest.Delete(newRepository))
})
})
}
// Middleware to convert Chi URL params (from Context) to query params, as expected by our REST package
func UrlParams(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := chi.RouteContext(r.Context())
parts := make([]string, 0)
for i, key := range ctx.URLParams.Keys {
value := ctx.URLParams.Values[i]
if key == "*" {
continue
}
parts = append(parts, url.QueryEscape(":"+key)+"="+url.QueryEscape(value))
}
q := strings.Join(parts, "&")
if r.URL.RawQuery == "" {
r.URL.RawQuery = q
} else {
r.URL.RawQuery += "&" + q
}
next.ServeHTTP(w, r)
})
}

View file

@ -1,12 +1,13 @@
// in src/App.js
import React from 'react'
import { Admin, ListGuesser, Resource } from 'react-admin'
import { Admin, Resource } from 'react-admin'
import jsonServerProvider from 'ra-data-json-server'
import user from './user'
const dataProvider = jsonServerProvider('http://jsonplaceholder.typicode.com')
const dataProvider = jsonServerProvider('/app/api')
const App = () => (
<Admin dataProvider={dataProvider}>
<Resource name="users" list={ListGuesser} />
<Resource name="user" {...user} />
</Admin>
)
export default App

48
ui/src/user/UserList.js Normal file
View file

@ -0,0 +1,48 @@
import React from 'react'
import {
BooleanField,
Datagrid,
DateField,
Filter,
List,
SearchInput,
SimpleList,
TextField
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
const UserFilter = (props) => (
<Filter {...props}>
<SearchInput source="q" alwaysOn />
</Filter>
)
const UserList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
return (
<List
{...props}
sort={{ field: 'name', order: 'ASC' }}
exporter={false}
filters={<UserFilter />}
>
{isXsmall ? (
<SimpleList
primaryText={(record) => record.name}
secondaryText={(record) => record.email}
/>
) : (
<Datagrid>
<TextField source="name" />
<BooleanField source="isAdmin" />
<DateField source="lastLoginAt" locales="pt-BR" />
<DateField source="lastAccessAt" locales="pt-BR" />
<DateField source="updatedAt" locales="pt-BR" />
</Datagrid>
)}
</List>
)
}
export default UserList

11
ui/src/user/index.js Normal file
View file

@ -0,0 +1,11 @@
import SupervisedUserCircleIcon from '@material-ui/icons/SupervisedUserCircle'
import UserList from './UserList'
// import UserEdit from './UserEdit'
// import UserCreate from './UserCreate'
export default {
list: UserList,
// edit: UserEdit,
// create: UserCreate,
icon: SupervisedUserCircleIcon
}