add cruds

This commit is contained in:
Simon Vieille 2024-09-17 21:05:54 +02:00
commit a1dfbad0ad
13 changed files with 565 additions and 186 deletions

View file

@ -36,6 +36,7 @@ func main() {
conf := config.Get()
conf.Load(*ini)
manager.Get().Db.AutoMigrate(&model.User{})
manager.Get().Db.AutoMigrate(&model.BankAccount{})
e := echo.New()
e.Use(session.Middleware(sessions.NewCookieStore([]byte("secret"))))

View file

@ -0,0 +1,20 @@
package model
import (
"time"
)
type BankAccount struct {
ID uint `gorm:"primaryKey" json:"id"`
Label string `gorm:"unique;not null" json:"label"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func NewBankAccount(label string) *BankAccount {
account := BankAccount{
Label: label,
}
return &account
}

View file

@ -11,9 +11,9 @@ import (
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique" json:"username"`
Password string `json:"-"`
DisplayName string `json:"display_name"`
Username string `gorm:"unique;not null" json:"username"`
Password string `gorm:"not null" json:"-"`
DisplayName string `gorm:"not null" json:"display_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View file

@ -17,6 +17,9 @@ import { BNavItem } from 'bootstrap-vue-next'
<RouterLink to="/users" custom v-slot="{ href, route, navigate, isActive, isExactActive }">
<BNavItem :href="href" :active="isActive">{{ route.name }}</BNavItem>
</RouterLink>
<RouterLink to="/bank_accounts" custom v-slot="{ href, route, navigate, isActive, isExactActive }">
<BNavItem :href="href" :active="isActive">{{ route.name }}</BNavItem>
</RouterLink>
</ul>
</div>
<RouterView id="body" />

View file

@ -2,8 +2,10 @@
<span>
{{ props.label }}
<i v-if="isAsc()" class="ms-1 fa-solid fa-sort-up"></i>
<i v-if="isDesc()" class="ms-1 fa-solid fa-sort-down"></i>
<span v-if="isAsc() || isDesc()" class="text-black-50">
<i v-if="isAsc()" class="ms-1 fa-solid fa-sort-up"></i>
<i v-if="isDesc()" class="ms-1 fa-solid fa-sort-down"></i>
</span>
</span>
</template>

View file

@ -13,6 +13,11 @@ const router = createRouter({
name: 'Utilisateurs',
component: () => import('../views/UsersView.vue')
},
{
path: '/bank_accounts',
name: 'Comptes bancaires',
component: () => import('../views/BankAccountsView.vue')
},
]
})

View file

@ -0,0 +1,226 @@
<template>
<BContainer fluid class="p-0">
<div class="d-flex justify-content-between p-3 mb-3">
<h3>Comptes bancaires</h3>
<BButtonToolbar key-nav>
<BButton @click="doAdd">Ajouter</BButton>
</BButtonToolbar>
</div>
<BTableSimple caption-top responsive v-if="data !== null">
<BThead>
<BTr>
<BTh v-for="field in fields" :width="field.width" class="cursor" :class="field.classes" @click="doSort(field.key)">
<SortButton :currentOrder="order" :currentSort="sort" :order="field.key" :label="field.label" />
</BTh>
</Btr>
</BThead>
<BTbody>
<BTr v-for="row in data.rows">
<BTd v-for="field in fields" @click="doEdit(row)" class="cursor">
{{ row[field.key] }}
</BTd>
</Btr>
</BTbody>
</BTableSimple>
<BModal v-if="form !== null" v-model="formShow" :title="form?.label" @ok="doSave" footer-class="justify-content-between">
<BAlert :model-value="form.error !== null" variant="danger" v-text="form.error"></BAlert>
<BForm @submit="doSave">
<BFormGroup
class="mb-2"
v-for="(field, key) in form.fields"
:id="'form-label-' + key"
:label="field.label"
:label-for="'form-label-' + key"
:description="field.description"
>
<BFormInput
v-if="(field.widget ?? 'text') === 'text'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="field.type"
:required="field.required"
/>
</BFormGroup>
</BForm>
<template v-slot:footer>
<div>
<BButton v-if="form.data.id" variant="danger" @click="doDelete">Supprimer</BButton>
</div>
<div>
<BButton variant="secondary" class="me-2" @click="formShow = false">Annuler</BButton>
<BButton variant="primary" @click="doSave">OK</BButton>
</div>
</template>
</BModal>
</BContainer>
</template>
<script setup>
import {
BTbody,
BThead,
BTr,
BTd,
BTh,
BContainer,
BTableSimple,
BModal,
BButton,
BForm,
BFormGroup,
BFormInput,
BAlert,
BButtonToolbar,
} from 'bootstrap-vue-next'
import SortButton from './../components/SortButton.vue'
import {ref, onMounted, reactive} from 'vue'
const data = ref(null)
const order = ref(null)
const sort = ref(null)
const page = ref(1)
const pages = ref(null)
const limit = ref(null)
const form = ref(null)
const formShow = ref(false)
const endpoint = `/api/bank_account`
const refresh = () => {
fetch(`${endpoint}?${new URLSearchParams({
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
})}`)
.then((response) => {
return response.json()
})
.then(function(value) {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
})
}
const doEdit = (item) => {
const data = {...item}
form.value = {
action: `${endpoint}/${item.id}`,
method: 'POST',
data: data,
label: data.label,
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
]
}
formShow.value = true
}
const doAdd = () => {
const data = {label: null}
form.value = {
action: `${endpoint}`,
method: 'POST',
data: data,
label: 'Nouveau',
error: null,
fields: [
{
label: 'Libellé',
type: 'text',
required: true,
key: 'label',
},
]
}
formShow.value = true
}
const doDelete = (e) => {
fetch(`${endpoint}/${form.value.data.id}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
})
.then(() => {
formShow.value = false
refresh()
})
}
const doSave = (e) => {
e.preventDefault()
const url = form.value.data.id ? `${endpoint}/${form.value.data.id}` : endpoint
fetch(url, {
method: form.value.method,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(form.value.data),
})
.then((response) => {
return response.json()
})
.then((data) => {
if (data.code === 400) {
form.value.error = data.message
} else {
form.value = null
formShow.value = false
refresh()
}
})
.catch((err) => {
form.value.error = `Une erreur s'est produite : ${err}`
})
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = (sort.value === 'asc' ? 'desc' : 'asc')
}
order.value = key
sort.value = nextSort
page.value = 1
refresh()
}
const fields = [
{
key: 'id',
label: 'ID',
width: '70px',
},
{
key: 'label',
label: 'Libellé',
},
]
onMounted(() => {
refresh()
})
</script>

View file

@ -1,168 +1,5 @@
<template>
<BContainer fluid>
<BTable
v-model:sort-by="sortBy"
:sort-internal="true"
:items="itemsTyped"
:fields="fieldsTyped"
:current-page="currentPage"
:per-page="perPage"
:filter="filter"
:responsive="false"
:filterable="filterOn"
:small="true"
:multisort="true"
@filtered="onFiltered"
>
<template #cell(name)="row">
{{ (row.value).first }}
{{ (row.value).last }}
</template>
<template #cell(actions)="row">
<BButton size="sm" class="mr-1" @click="info(row.item, row.index)"> Info modal </BButton>
<BButton size="sm" @click="row.toggleDetails">
{{ row.detailsShowing ? 'Hide' : 'Show' }} Details
</BButton>
</template>
<template #row-details="row">
<BCard>
<ul>
<li v-for="(value, key) in row.item" :key="key">{{ key }}: {{ value }}</li>
<BButton size="sm" @click="row.toggleDetails"> Toggle Details </BButton>
</ul>
</BCard>
</template>
</BTable>
<BModal
:id="infoModal.id"
v-model="infoModal.open"
:title="infoModal.title"
:ok-only="true"
@hide="resetInfoModal"
>
<pre>{{ infoModal.content }}</pre>
</BModal>
</BContainer>
</template>
<script setup>
import {
BButton,
BFormSelect,
BInputGroup,
BFormCheckbox,
BFormGroup,
BCol,
BFormInput,
BInputGroupText,
BFormCHeckbox,
BPagination,
BRow,
BModal,
BContainer,
BTable,
BTableSortBy
} from 'bootstrap-vue-next'
import {computed, reactive, ref} from 'vue'
const itemsTyped = [
{isActive: true, age: 40, name: {first: 'Dickerson', last: 'Macdonald'}},
{isActive: false, age: 21, name: {first: 'Larsen', last: 'Shaw'}},
{
isActive: false,
age: 9,
name: {first: 'Mini', last: 'Navarro'},
_rowVariant: 'success',
},
{isActive: false, age: 89, name: {first: 'Geneva', last: 'Wilson'}},
{isActive: true, age: 38, name: {first: 'Jami', last: 'Carney'}},
{isActive: false, age: 27, name: {first: 'Essie', last: 'Dunlap'}},
{isActive: true, age: 40, name: {first: 'Thor', last: 'Macdonald'}},
{
isActive: true,
age: 87,
name: {first: 'Larsen', last: 'Shaw'},
_cellVariants: {age: 'danger', isActive: 'warning'},
},
{isActive: false, age: 26, name: {first: 'Mitzi', last: 'Navarro'}},
{isActive: false, age: 22, name: {first: 'Genevieve', last: 'Wilson'}},
{isActive: true, age: 38, name: {first: 'John', last: 'Carney'}},
{isActive: false, age: 29, name: {first: 'Dick', last: 'Dunlap'}},
]
const fieldsTyped = [
{
key: 'name',
label: 'Person full name',
sortable: true,
sortDirection: 'desc',
},
{
key: 'sortableName',
label: 'Person sortable name',
sortable: true,
sortDirection: 'desc',
formatter: (_value, _key, item) =>
item ? `${item.name.last}, ${item.name.first}` : 'Something went wrong',
sortByFormatted: true,
filterByFormatted: true,
},
{key: 'age', label: 'Person age', sortable: true, class: 'text-center'},
{
key: 'isActive',
label: 'Is Active',
formatter: (value) => (value ? 'Yes' : 'No'),
sortable: true,
sortByFormatted: true,
filterByFormatted: true,
},
{key: 'actions', label: 'Actions'},
]
const pageOptions = [
{value: 5, text: '5'},
{value: 10, text: '10'},
{value: 15, text: '15'},
{value: 100, text: 'Show a lot'},
]
const totalRows = ref(itemsTyped.length)
const currentPage = ref(1)
const perPage = ref(5)
const sortBy = ref([])
const sortDirection = ref('asc')
const filter = ref('')
const filterOn = ref([])
const infoModal = reactive({
open: false,
id: 'info-modal',
title: '',
content: '',
})
// Create an options list from our fields
const sortOptions = computed(() =>
fieldsTyped.filter((f) => f.sortable).map((f) => ({text: f.label, value: f.key}))
)
function info(item, index) {
infoModal.title = `Row index: ${index}`
infoModal.content = JSON.stringify(item, null, 2)
infoModal.open = true
}
function resetInfoModal() {
infoModal.title = ''
infoModal.content = ''
}
function onFiltered(filteredItems) {
// Trigger pagination to update the number of buttons/pages due to filtering
totalRows.value = filteredItems.length
currentPage.value = 1
}
function onAddSort() {
sortBy.value.push({key: '', order: 'asc'})
}
</script>

View file

@ -1,5 +1,11 @@
<template>
<BContainer fluid class="p-0">
<div class="d-flex justify-content-between p-3 mb-3">
<h3>Utilisateurs</h3>
<BButtonToolbar key-nav>
<BButton @click="doAdd">Ajouter</BButton>
</BButtonToolbar>
</div>
<BTableSimple caption-top responsive v-if="data !== null">
<BThead>
<BTr>
@ -28,13 +34,23 @@
:description="field.description"
>
<BFormInput
v-if="(field.widget ?? 'text') === 'text'"
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="form.type"
:required="form.required"
:type="field.type"
:required="field.required"
/>
</BFormGroup>
</BForm>
<template v-slot:footer>
<div>
<BButton v-if="form.data.id" variant="danger" @click="doDelete">Supprimer</BButton>
</div>
<div>
<BButton variant="secondary" class="me-2" @click="formShow = false">Annuler</BButton>
<BButton variant="primary" @click="doSave">OK</BButton>
</div>
</template>
</BModal>
</BContainer>
</template>
@ -46,7 +62,6 @@ import {
BTr,
BTd,
BTh,
BRow,
BContainer,
BTableSimple,
BModal,
@ -55,6 +70,7 @@ import {
BFormGroup,
BFormInput,
BAlert,
BButtonToolbar,
} from 'bootstrap-vue-next'
import SortButton from './../components/SortButton.vue'
@ -63,15 +79,21 @@ import {ref, onMounted, reactive} from 'vue'
const data = ref(null)
const order = ref(null)
const sort = ref(null)
const page = ref(null)
const page = ref(1)
const pages = ref(null)
const limit = ref(null)
const form = ref(null)
const formShow = ref(false)
const endpoint = `/api/user`
const refresh = (query) => {
fetch(`/api/user?${new URLSearchParams(query)}`)
.then(function(response) {
const refresh = () => {
fetch(`${endpoint}?${new URLSearchParams({
order: order.value,
sort: sort.value,
page: page.value,
limit: limit.value,
})}`)
.then((response) => {
return response.json()
})
.then(function(value) {
@ -85,11 +107,13 @@ const refresh = (query) => {
}
const doEdit = (item) => {
const data = {...item}
form.value = {
action: `api/user/?${item.id}`,
action: `${endpoint}/${item.id}`,
method: 'POST',
data: item,
label: item.display_name,
data: data,
label: data.display_name,
error: null,
fields: [
{
@ -116,8 +140,82 @@ const doEdit = (item) => {
formShow.value = true
}
const doAdd = () => {
const data = {display_name: null, username: null, password: null}
form.value = {
action: `${endpoint}`,
method: 'POST',
data: data,
label: 'Nouveau',
error: null,
fields: [
{
label: 'Nom',
type: 'text',
required: true,
key: 'display_name',
},
{
label: 'Nom d\'utilisateur',
type: 'text',
required: true,
key: 'username',
},
{
label: 'Mot de passe',
type: 'password',
required: false,
key: 'password',
},
]
}
formShow.value = true
}
const doDelete = (e) => {
fetch(`${endpoint}/${form.value.data.id}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
})
.then(() => {
formShow.value = false
refresh()
})
}
const doSave = (e) => {
console.log(form.value)
e.preventDefault()
const url = form.value.data.id ? `${endpoint}/${form.value.data.id}` : endpoint
fetch(url, {
method: form.value.method,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(form.value.data),
})
.then((response) => {
return response.json()
})
.then((data) => {
if (data.code === 400) {
form.value.error = data.message
} else {
form.value = null
formShow.value = false
refresh()
}
})
.catch((err) => {
form.value.error = `Une erreur s'est produite : ${err}`
})
}
const doSort = (key) => {
@ -127,14 +225,11 @@ const doSort = (key) => {
nextSort = (sort.value === 'asc' ? 'desc' : 'asc')
}
refresh({
order: key,
sort: nextSort,
limit: limit,
page: 1,
})
order.value = key
sort.value = nextSort
page.value = 1
sort.value = key
refresh()
}
const fields = [

View file

@ -0,0 +1,102 @@
package bank_account
import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/database/model"
"gitnet.fr/deblan/budget/web/controller/crud"
"gorm.io/gorm"
)
type Controller struct {
crud *crud.Controller
}
func (ctrl *Controller) Config() crud.Configuration {
return crud.Configuration{
Model: model.BankAccount{},
Models: []model.BankAccount{},
ValidOrders: []string{"id", "display_name", "username"},
ValidLimits: []int{20, 50, 100},
DefaultLimit: 20,
CreateModel: func() interface{} {
return new(model.BankAccount)
},
}
}
func New(e *echo.Echo) *Controller {
c := Controller{
crud: crud.New(),
}
e.GET("/api/bank_account", c.List)
e.POST("/api/bank_account", c.Create)
e.GET("/api/bank_account/:id", c.Show)
e.POST("/api/bank_account/:id", c.Update)
e.DELETE("/api/bank_account/:id", c.Delete)
return &c
}
func (ctrl *Controller) List(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
return ctrl.crud.With(ctrl.Config()).List(c)
}
func (ctrl *Controller) Show(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
return ctrl.crud.With(ctrl.Config()).Show(c)
}
func (ctrl *Controller) Delete(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
return ctrl.crud.With(ctrl.Config()).Delete(c)
}
func (ctrl *Controller) Create(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
type body struct {
Label string `json:"label" form:"label" validate:"required"`
}
return ctrl.crud.With(ctrl.Config()).Create(c, new(body), func(db *gorm.DB, v interface{}) (interface{}, error) {
value := v.(*body)
item := model.NewBankAccount(value.Label)
db.Model(ctrl.crud.Config.Model).Create(&item)
return item, nil
})
}
func (ctrl *Controller) Update(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
type body struct {
Label string `json:"label" form:"label" validate:"required"`
}
return ctrl.crud.With(ctrl.Config()).Update(c, new(body), func(db *gorm.DB, a, b interface{}) (interface{}, error) {
item := a.(*model.BankAccount)
update := b.(*body)
item.Label = update.Label
db.Model(ctrl.crud.Config.Model).Where("id = ?", item.ID).Save(&item)
return item, nil
})
}

View file

@ -11,6 +11,7 @@ import (
)
type UpdateCallback func(*gorm.DB, interface{}, interface{}) (interface{}, error)
type CreateCallback func(*gorm.DB, interface{}) (interface{}, error)
type Error struct {
Code int `json:"code"`
@ -96,6 +97,59 @@ func (ctrl *Controller) Show(c echo.Context) error {
return c.JSON(200, item)
}
func (ctrl *Controller) Delete(c echo.Context) error {
db := manager.Get().Db
value, err := strconv.Atoi(c.Param("id"))
if err != nil {
return err
}
var count int64
db.Model(ctrl.Config.Model).Where("id = ?", value).Count(&count)
if count == 0 {
return c.JSON(404, Error{
Code: 404,
Message: "Not found",
})
}
item := ctrl.Config.CreateModel()
db.Model(ctrl.Config.Model).Where("id = ?", value).Delete(&item)
return c.JSON(200, nil)
}
func (ctrl *Controller) Create(c echo.Context, body interface{}, createCallback CreateCallback) error {
db := manager.Get().Db
if err := c.Bind(body); err != nil {
return c.JSON(400, Error{
Code: 400,
Message: "Bad request",
})
}
if err := c.Validate(body); err != nil {
return c.JSON(400, Error{
Code: 400,
Message: err.Error(),
})
}
result, err := createCallback(db, body)
if err != nil {
return c.JSON(400, Error{
Code: 400,
Message: err.Error(),
})
}
return c.JSON(200, result)
}
func (ctrl *Controller) Update(c echo.Context, body interface{}, updateCallback UpdateCallback) error {
db := manager.Get().Db
value, err := strconv.Atoi(c.Param("id"))

View file

@ -30,8 +30,10 @@ func New(e *echo.Echo) *Controller {
}
e.GET("/api/user", c.List)
e.POST("/api/user", c.Create)
e.GET("/api/user/:id", c.Show)
e.POST("/api/user/:id", c.Update)
e.DELETE("/api/user/:id", c.Delete)
return &c
}
@ -52,12 +54,42 @@ func (ctrl *Controller) Show(c echo.Context) error {
return ctrl.crud.With(ctrl.Config()).Show(c)
}
func (ctrl *Controller) Delete(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
return ctrl.crud.With(ctrl.Config()).Delete(c)
}
func (ctrl *Controller) Create(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
type body struct {
Username string `json:"username" form:"username" validate:"required"`
DisplayName string `json:"display_name" form:"display_name" validate:"required"`
Password string `json:"password" form:"password" validate:"required"`
}
return ctrl.crud.With(ctrl.Config()).Create(c, new(body), func(db *gorm.DB, v interface{}) (interface{}, error) {
value := v.(*body)
item := model.NewUser(value.Username, value.Password, value.DisplayName)
db.Model(ctrl.crud.Config.Model).Save(&item)
return item, nil
})
}
func (ctrl *Controller) Update(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
type body struct {
Username string `json:"username" form:"username" validate:"required"`
DisplayName string `json:"display_name" form:"display_name" validate:"required"`
Password string `json:"password" form:"password"`
}

View file

@ -4,6 +4,7 @@ import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/web/controller/app"
"gitnet.fr/deblan/budget/web/controller/auth"
"gitnet.fr/deblan/budget/web/controller/bank_account"
"gitnet.fr/deblan/budget/web/controller/user"
)
@ -11,4 +12,5 @@ func RegisterControllers(e *echo.Echo) {
auth.New(e)
app.New(e)
user.New(e)
bank_account.New(e)
}