diff --git a/api/application.go b/api/application.go index 6fa0f956..c63d1d3b 100644 --- a/api/application.go +++ b/api/application.go @@ -44,6 +44,10 @@ type ApplicationParams struct { // // example: Backup server for the interwebs Description string `form:"description" query:"description" json:"description"` + // The default priority of messages sent by this application. Defaults to 0. + // + // example: 5 + DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` } // CreateApplication creates an application and returns the access token. @@ -83,11 +87,12 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { applicationParams := ApplicationParams{} if err := ctx.Bind(&applicationParams); err == nil { app := model.Application{ - Name: applicationParams.Name, - Description: applicationParams.Description, - Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists), - UserID: auth.GetUserID(ctx), - Internal: false, + Name: applicationParams.Name, + Description: applicationParams.Description, + DefaultPriority: applicationParams.DefaultPriority, + Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists), + UserID: auth.GetUserID(ctx), + Internal: false, } if success := successOrAbort(ctx, 500, a.DB.CreateApplication(&app)); !success { @@ -245,6 +250,7 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) { if err := ctx.Bind(&applicationParams); err == nil { app.Description = applicationParams.Description app.Name = applicationParams.Name + app.DefaultPriority = applicationParams.DefaultPriority if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success { return diff --git a/api/application_test.go b/api/application_test.go index 6312a0a2..3c4bc960 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -92,7 +92,7 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() Image: "asd", Internal: true, } - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true}`) + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0}`) } func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { @@ -527,6 +527,29 @@ func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() { } } +func (s *ApplicationSuite) Test_UpdateApplicationDefaultPriority_expectSuccess() { + s.db.User(5).NewAppWithToken(2, "app-2") + + test.WithUser(s.ctx, 5) + s.withFormData("name=name&description=&defaultPriority=4") + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.a.UpdateApplication(s.ctx) + + expected := &model.Application{ + ID: 2, + Token: "app-2", + UserID: 5, + Name: "name", + Description: "", + DefaultPriority: 4, + } + + assert.Equal(s.T(), 200, s.recorder.Code) + if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { + assert.Equal(s.T(), expected, app) + } +} + func (s *ApplicationSuite) Test_UpdateApplication_preservesImage() { app := s.db.User(5).NewAppWithToken(2, "app-2") app.Image = "existing.png" diff --git a/api/message.go b/api/message.go index 4b3c37d6..3a4b2420 100644 --- a/api/message.go +++ b/api/message.go @@ -371,6 +371,11 @@ func (a *MessageAPI) CreateMessage(ctx *gin.Context) { if strings.TrimSpace(message.Title) == "" { message.Title = application.Name } + + if message.Priority == nil { + message.Priority = &application.DefaultPriority + } + message.Date = timeNow() message.ID = 0 msgInternal := toInternalMessage(&message) @@ -388,9 +393,12 @@ func toInternalMessage(msg *model.MessageExternal) *model.Message { ApplicationID: msg.ApplicationID, Message: msg.Message, Title: msg.Title, - Priority: msg.Priority, Date: msg.Date, } + if msg.Priority != nil { + res.Priority = *msg.Priority + } + if msg.Extras != nil { res.Extras, _ = json.Marshal(msg.Extras) } @@ -403,7 +411,7 @@ func toExternalMessage(msg *model.Message) *model.MessageExternal { ApplicationID: msg.ApplicationID, Message: msg.Message, Title: msg.Title, - Priority: msg.Priority, + Priority: &msg.Priority, Date: msg.Date, } if len(msg.Extras) != 0 { diff --git a/api/message_test.go b/api/message_test.go index 0f5db8fb..358732e2 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -53,7 +53,7 @@ func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() { actual := &model.PagedMessages{ Paging: model.Paging{Limit: 5, Since: 122, Size: 5, Next: "http://example.com/message?limit=5&since=122"}, - Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: 4, Extras: map[string]interface{}{ + Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: intPtr(4), Extras: map[string]interface{}{ "test::string": "string", "test::array": []interface{}{1, 2, 3}, "test::int": 1, @@ -331,7 +331,29 @@ func (s *MessageSuite) Test_CreateMessage_onJson_allParams() { msgs, err := s.db.GetMessagesByApplication(7) assert.NoError(s.T(), err) - expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: 1, Date: t} + expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} + assert.Len(s.T(), msgs, 1) + assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), expected, s.notifiedMessage) +} + +func (s *MessageSuite) Test_CreateMessage_WithDefaultPriority() { + t, _ := time.Parse("2006/01/02", "2017/01/02") + + timeNow = func() time.Time { return t } + defer func() { timeNow = time.Now }() + + auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + s.db.User(4).AppWithTokenAndDefaultPriority(8, "app-token", 5) + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + msgs, err := s.db.GetMessagesByApplication(8) + assert.NoError(s.T(), err) + expected := &model.MessageExternal{ID: 1, ApplicationID: 8, Title: "mytitle", Message: "mymessage", Priority: intPtr(5), Date: t} assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) assert.Equal(s.T(), 200, s.recorder.Code) @@ -352,7 +374,7 @@ func (s *MessageSuite) Test_CreateMessage_WithTitle() { msgs, err := s.db.GetMessagesByApplication(5) assert.NoError(s.T(), err) - expected := &model.MessageExternal{ID: 1, ApplicationID: 5, Title: "mytitle", Message: "mymessage", Date: t} + expected := &model.MessageExternal{ID: 1, ApplicationID: 5, Title: "mytitle", Message: "mymessage", Date: t, Priority: intPtr(0)} assert.Len(s.T(), msgs, 1) assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) assert.Equal(s.T(), 200, s.recorder.Code) @@ -446,6 +468,7 @@ func (s *MessageSuite) Test_CreateMessage_WithExtras() { Message: "mymessage", Title: "msg with extras", Date: t, + Priority: intPtr(0), Extras: map[string]interface{}{ "gotify::test": map[string]interface{}{ "string": "test", @@ -492,7 +515,7 @@ func (s *MessageSuite) Test_CreateMessage_onQueryData() { s.a.CreateMessage(s.ctx) - expected := &model.MessageExternal{ID: 1, ApplicationID: 2, Title: "mytitle", Message: "mymessage", Priority: 1, Date: t} + expected := &model.MessageExternal{ID: 1, ApplicationID: 2, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} msgs, err := s.db.GetMessagesByApplication(2) assert.NoError(s.T(), err) @@ -515,7 +538,7 @@ func (s *MessageSuite) Test_CreateMessage_onFormData() { s.a.CreateMessage(s.ctx) - expected := &model.MessageExternal{ID: 1, ApplicationID: 99, Title: "mytitle", Message: "mymessage", Priority: 1, Date: t} + expected := &model.MessageExternal{ID: 1, ApplicationID: 99, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} msgs, err := s.db.GetMessagesByApplication(99) assert.NoError(s.T(), err) assert.Len(s.T(), msgs, 1) @@ -528,3 +551,7 @@ func (s *MessageSuite) withURL(scheme, host, path, query string) { s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query} s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) } + +func intPtr(x int) *int { + return &x +} diff --git a/docs/spec.json b/docs/spec.json index 6bb5aafb..fdbe4550 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -2063,6 +2063,13 @@ "image" ], "properties": { + "defaultPriority": { + "description": "The default priority of messages sent by this application. Defaults to 0.", + "type": "integer", + "format": "int64", + "x-go-name": "DefaultPriority", + "example": 4 + }, "description": { "description": "The description of the application.", "type": "string", @@ -2115,6 +2122,13 @@ "name" ], "properties": { + "defaultPriority": { + "description": "The default priority of messages sent by this application. Defaults to 0.", + "type": "integer", + "format": "int64", + "x-go-name": "DefaultPriority", + "example": 5 + }, "description": { "description": "The description of the application.", "type": "string", @@ -2326,7 +2340,7 @@ "example": "**Backup** was successfully finished." }, "priority": { - "description": "The priority of the message.", + "description": "The priority of the message. If unset, then the default priority of the\napplication will be used.", "type": "integer", "format": "int64", "x-go-name": "Priority", diff --git a/model/application.go b/model/application.go index d8c9e12c..3c69e7f0 100644 --- a/model/application.go +++ b/model/application.go @@ -42,4 +42,9 @@ type Application struct { // example: image/image.jpeg Image string `gorm:"type:text" json:"image"` Messages []MessageExternal `json:"-"` + // The default priority of messages sent by this application. Defaults to 0. + // + // required: false + // example: 4 + DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"` } diff --git a/model/message.go b/model/message.go index 164ad70e..dbee2ec6 100644 --- a/model/message.go +++ b/model/message.go @@ -42,10 +42,11 @@ type MessageExternal struct { // // example: Backup Title string `form:"title" query:"title" json:"title"` - // The priority of the message. + // The priority of the message. If unset, then the default priority of the + // application will be used. // // example: 2 - Priority int `form:"priority" query:"priority" json:"priority"` + Priority *int `form:"priority" query:"priority" json:"priority"` // The extra data sent along the message. // // The extra fields are stored in a key-value scheme. Only accepted in CreateMessage requests with application/json content-type. diff --git a/plugin/manager.go b/plugin/manager.go index 1df99ed4..e1f90b88 100644 --- a/plugin/manager.go +++ b/plugin/manager.go @@ -70,7 +70,7 @@ func NewManager(db Database, directory string, mux *gin.RouterGroup, notifier No internalMsg := &model.Message{ ApplicationID: message.Message.ApplicationID, Title: message.Message.Title, - Priority: message.Message.Priority, + Priority: *message.Message.Priority, Date: message.Message.Date, Message: message.Message.Message, } diff --git a/plugin/messagehandler.go b/plugin/messagehandler.go index a703bfd7..01f01c5d 100644 --- a/plugin/messagehandler.go +++ b/plugin/messagehandler.go @@ -26,7 +26,7 @@ func (c redirectToChannel) SendMessage(msg compat.Message) error { ApplicationID: c.ApplicationID, Message: msg.Message, Title: msg.Title, - Priority: msg.Priority, + Priority: &msg.Priority, Date: time.Now(), Extras: msg.Extras, }, diff --git a/test/testdb/database.go b/test/testdb/database.go index 2abe254b..e12d0122 100644 --- a/test/testdb/database.go +++ b/test/testdb/database.go @@ -138,6 +138,13 @@ func (ab *AppClientBuilder) newAppWithTokenAndName(id uint, token, name string, return application } +// AppWithTokenAndDefaultPriority creates an application with a token and defaultPriority and returns a message builder. +func (ab *AppClientBuilder) AppWithTokenAndDefaultPriority(id uint, token string, defaultPriority int) *MessageBuilder { + application := &model.Application{ID: id, UserID: ab.userID, Token: token, DefaultPriority: defaultPriority} + ab.db.CreateApplication(application) + return &MessageBuilder{db: ab.db, appID: id} +} + // Client creates a client and returns itself. func (ab *AppClientBuilder) Client(id uint) *AppClientBuilder { return ab.ClientWithToken(id, "client"+fmt.Sprint(id)) diff --git a/test/testdb/database_test.go b/test/testdb/database_test.go index 17a80aeb..3dd4b336 100644 --- a/test/testdb/database_test.go +++ b/test/testdb/database_test.go @@ -127,6 +127,7 @@ func (s *DatabaseSuite) Test_Apps() { userBuilder.InternalAppWithTokenAndName(10, "test-tokeni-2", "app name") userBuilder.AppWithToken(11, "test-token-3") userBuilder.InternalAppWithToken(12, "test-tokeni-3") + userBuilder.AppWithTokenAndDefaultPriority(13, "test-tokeni-4", 4) s.db.AssertAppExist(1) s.db.AssertAppExist(2) @@ -140,6 +141,7 @@ func (s *DatabaseSuite) Test_Apps() { s.db.AssertAppExist(10) s.db.AssertAppExist(11) s.db.AssertAppExist(12) + s.db.AssertAppExist(13) s.db.DeleteApplicationByID(2) diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index 8b73b85f..dce6b929 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -6,27 +6,29 @@ import DialogContentText from '@material-ui/core/DialogContentText'; import DialogTitle from '@material-ui/core/DialogTitle'; import TextField from '@material-ui/core/TextField'; import Tooltip from '@material-ui/core/Tooltip'; +import {NumberField} from '../common/NumberField'; import React, {Component} from 'react'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string, description: string) => void; + fOnSubmit: (name: string, description: string, defaultPriority: number) => void; } interface IState { name: string; description: string; + defaultPriority: number; } export default class AddDialog extends Component { - public state = {name: '', description: ''}; + public state = {name: '', description: '', defaultPriority: 0}; public render() { const {fClose, fOnSubmit} = this.props; - const {name, description} = this.state; + const {name, description, defaultPriority} = this.state; const submitEnabled = this.state.name.length !== 0; const submitAndClose = () => { - fOnSubmit(name, description); + fOnSubmit(name, description, defaultPriority); fClose(); }; return ( @@ -59,6 +61,14 @@ export default class AddDialog extends Component { fullWidth multiline /> + this.setState({defaultPriority: value})} + fullWidth + /> diff --git a/ui/src/application/AppStore.ts b/ui/src/application/AppStore.ts index 73549071..dc5d5f2f 100644 --- a/ui/src/application/AppStore.ts +++ b/ui/src/application/AppStore.ts @@ -35,15 +35,32 @@ export class AppStore extends BaseStore { }; @action - public update = async (id: number, name: string, description: string): Promise => { - await axios.put(`${config.get('url')}application/${id}`, {name, description}); + public update = async ( + id: number, + name: string, + description: string, + defaultPriority: number + ): Promise => { + await axios.put(`${config.get('url')}application/${id}`, { + name, + description, + defaultPriority, + }); await this.refresh(); this.snack('Application updated'); }; @action - public create = async (name: string, description: string): Promise => { - await axios.post(`${config.get('url')}application`, {name, description}); + public create = async ( + name: string, + description: string, + defaultPriority: number + ): Promise => { + await axios.post(`${config.get('url')}application`, { + name, + description, + defaultPriority, + }); await this.refresh(); this.snack('Application created'); }; diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index 0718b306..a859b83d 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -66,6 +66,7 @@ class Applications extends Component> { Name Token Description + Priority @@ -75,6 +76,7 @@ class Applications extends Component> { > { {updateId !== false && ( (this.updateId = false)} - fOnSubmit={(name, description) => - appStore.update(updateId, name, description) + fOnSubmit={(name, description, defaultPriority) => + appStore.update(updateId, name, description, defaultPriority) } initialDescription={appStore.getByID(updateId).description} initialName={appStore.getByID(updateId).name} + initialDefaultPriority={appStore.getByID(updateId).defaultPriority} /> )} {deleteId !== false && ( @@ -147,6 +150,7 @@ interface IRowProps { value: string; noDelete: boolean; description: string; + defaultPriority: number; fUpload: VoidFunction; image: string; fDelete: VoidFunction; @@ -154,7 +158,7 @@ interface IRowProps { } const Row: SFC = observer( - ({name, value, noDelete, description, fDelete, fUpload, image, fEdit}) => ( + ({name, value, noDelete, description, defaultPriority, fDelete, fUpload, image, fEdit}) => (
@@ -169,6 +173,7 @@ const Row: SFC = observer( {description} + {defaultPriority} diff --git a/ui/src/application/UpdateApplicationDialog.tsx b/ui/src/application/UpdateApplicationDialog.tsx index a4038e0a..ed040224 100644 --- a/ui/src/application/UpdateApplicationDialog.tsx +++ b/ui/src/application/UpdateApplicationDialog.tsx @@ -6,37 +6,41 @@ import DialogContentText from '@material-ui/core/DialogContentText'; import DialogTitle from '@material-ui/core/DialogTitle'; import TextField from '@material-ui/core/TextField'; import Tooltip from '@material-ui/core/Tooltip'; +import {NumberField} from '../common/NumberField'; import React, {Component} from 'react'; interface IProps { fClose: VoidFunction; - fOnSubmit: (name: string, description: string) => void; + fOnSubmit: (name: string, description: string, defaultPriority: number) => void; initialName: string; initialDescription: string; + initialDefaultPriority: number; } interface IState { name: string; description: string; + defaultPriority: number; } export default class UpdateDialog extends Component { - public state = {name: '', description: ''}; + public state = {name: '', description: '', defaultPriority: 0}; constructor(props: IProps) { super(props); this.state = { name: props.initialName, description: props.initialDescription, + defaultPriority: props.initialDefaultPriority, }; } public render() { const {fClose, fOnSubmit} = this.props; - const {name, description} = this.state; + const {name, description, defaultPriority} = this.state; const submitEnabled = this.state.name.length !== 0; const submitAndClose = () => { - fOnSubmit(name, description); + fOnSubmit(name, description, defaultPriority); fClose(); }; return ( @@ -69,6 +73,14 @@ export default class UpdateDialog extends Component { fullWidth multiline /> + this.setState({defaultPriority: value})} + fullWidth + /> diff --git a/ui/src/common/NumberField.tsx b/ui/src/common/NumberField.tsx new file mode 100644 index 00000000..58952e05 --- /dev/null +++ b/ui/src/common/NumberField.tsx @@ -0,0 +1,36 @@ +import {TextField, TextFieldProps} from '@material-ui/core'; +import React from 'react'; + +export interface NumberFieldProps { + value: number; + onChange: (value: number) => void; +} + +export const NumberField = ({ + value, + onChange, + ...props +}: NumberFieldProps & Omit) => { + const [stringValue, setStringValue] = React.useState(value.toString()); + const [error, setError] = React.useState(''); + + return ( + { + setStringValue(event.target.value); + const i = parseInt(event.target.value, 10); + if (!Number.isNaN(i)) { + onChange(i); + setError(''); + } else { + setError('Invalid number'); + } + }} + {...props} + /> + ); +}; diff --git a/ui/src/tests/application.test.ts b/ui/src/tests/application.test.ts index f64a7981..da95624d 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -17,8 +17,9 @@ enum Col { Name = 2, Token = 3, Description = 4, - EditUpdate = 5, - EditDelete = 6, + DefaultPriority = 5, + EditUpdate = 6, + EditDelete = 7, } const hiddenToken = '•••••••••••••••'; diff --git a/ui/src/types.ts b/ui/src/types.ts index ad22775d..8e24d150 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -5,6 +5,7 @@ export interface IApplication { description: string; image: string; internal: boolean; + defaultPriority: number; } export interface IClient {