Add update client api and dialog (#164)

This commit is contained in:
GianOrtiz 2019-03-16 07:18:51 -03:00 committed by Jannis Mattheis
parent efcf4ad13d
commit e32359ed15
11 changed files with 318 additions and 5 deletions

View File

@ -15,6 +15,7 @@ type ClientDatabase interface {
GetClientByID(id uint) *model.Client GetClientByID(id uint) *model.Client
GetClientsByUser(userID uint) []*model.Client GetClientsByUser(userID uint) []*model.Client
DeleteClientByID(id uint) error DeleteClientByID(id uint) error
UpdateClient(client *model.Client) error
} }
// The ClientAPI provides handlers for managing clients and applications. // The ClientAPI provides handlers for managing clients and applications.
@ -24,6 +25,65 @@ type ClientAPI struct {
NotifyDeleted func(uint, string) NotifyDeleted func(uint, string)
} }
// UpdateClient updates a client by its id.
// swagger:operation PUT /client/{id} client updateClient
//
// Update a client.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: body
// in: body
// description: the client to update
// required: true
// schema:
// $ref: "#/definitions/Client"
// - name: id
// in: path
// description: the client id
// required: true
// type: integer
// responses:
// 200:
// description: Ok
// schema:
// $ref: "#/definitions/Client"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 404:
// description: Not Found
// schema:
// $ref: "#/definitions/Error"
func (a *ClientAPI) UpdateClient(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
if client := a.DB.GetClientByID(id); client != nil && client.UserID == auth.GetUserID(ctx) {
newValues := &model.Client{}
if err := ctx.Bind(newValues); err == nil {
client.Name = newValues.Name
a.DB.UpdateClient(client)
ctx.JSON(200, client)
}
} else {
ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id))
}
})
}
// CreateClient creates a client and returns the access token. // CreateClient creates a client and returns the access token.
// swagger:operation POST /client client createClient // swagger:operation POST /client client createClient
// //

View File

@ -168,6 +168,40 @@ func (s *ClientSuite) Test_DeleteClient() {
assert.True(s.T(), s.notified) assert.True(s.T(), s.notified)
} }
func (s *ClientSuite) Test_UpdateClient_expectSuccess() {
s.db.User(5).NewClientWithToken(1, firstClientToken)
test.WithUser(s.ctx, 5)
s.withFormData("name=firefox")
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
s.a.UpdateClient(s.ctx)
expected := &model.Client{
ID: 1,
Token: firstClientToken,
UserID: 5,
Name: "firefox",
}
assert.Equal(s.T(), 200, s.recorder.Code)
assert.Equal(s.T(), expected, s.db.GetClientByID(1))
}
func (s *ClientSuite) Test_UpdateClient_expectNotFound() {
test.WithUser(s.ctx, 5)
s.ctx.Params = gin.Params{{Key: "id", Value: "2"}}
s.a.UpdateClient(s.ctx)
assert.Equal(s.T(), 404, s.recorder.Code)
}
func (s *ClientSuite) Test_UpdateClient_WithMissingAttributes_expectBadRequest() {
test.WithUser(s.ctx, 5)
s.a.UpdateClient(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}
func (s *ClientSuite) withFormData(formData string) { func (s *ClientSuite) withFormData(formData string) {
s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData))
s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

View File

@ -38,3 +38,8 @@ func (d *GormDatabase) GetClientsByUser(userID uint) []*model.Client {
func (d *GormDatabase) DeleteClientByID(id uint) error { func (d *GormDatabase) DeleteClientByID(id uint) error {
return d.DB.Where("id = ?", id).Delete(&model.Client{}).Error return d.DB.Where("id = ?", id).Delete(&model.Client{}).Error
} }
// UpdateClient updates a client.
func (d *GormDatabase) UpdateClient(client *model.Client) error {
return d.DB.Save(client).Error
}

View File

@ -29,6 +29,11 @@ func (s *DatabaseSuite) TestClient() {
newClient = s.db.GetClientByToken(client.Token) newClient = s.db.GetClientByToken(client.Token)
assert.Equal(s.T(), client, newClient) assert.Equal(s.T(), client, newClient)
updateClient := &model.Client{ID: client.ID, UserID: user.ID, Token: "C0000000000", Name: "new_name"}
s.db.UpdateClient(updateClient)
updatedClient := s.db.GetClientByID(client.ID)
assert.Equal(s.T(), updateClient, updatedClient)
s.db.DeleteClientByID(client.ID) s.db.DeleteClientByID(client.ID)
clients = s.db.GetClientsByUser(user.ID) clients = s.db.GetClientsByUser(user.ID)

View File

@ -16,7 +16,7 @@
// //
// Schemes: http, https // Schemes: http, https
// Host: localhost // Host: localhost
// Version: 2.0.0 // Version: 2.0.1
// License: MIT https://github.com/gotify/server/blob/master/LICENSE // License: MIT https://github.com/gotify/server/blob/master/LICENSE
// //
// Consumes: // Consumes:

View File

@ -17,7 +17,7 @@
"name": "MIT", "name": "MIT",
"url": "https://github.com/gotify/server/blob/master/LICENSE" "url": "https://github.com/gotify/server/blob/master/LICENSE"
}, },
"version": "2.0.0" "version": "2.0.1"
}, },
"host": "localhost", "host": "localhost",
"paths": { "paths": {
@ -599,6 +599,80 @@
} }
}, },
"/client/{id}": { "/client/{id}": {
"put": {
"security": [
{
"clientTokenHeader": []
},
{
"clientTokenQuery": []
},
{
"basicAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"client"
],
"summary": "Update a client.",
"operationId": "updateClient",
"parameters": [
{
"description": "the client to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/Client"
}
},
{
"type": "integer",
"description": "the client id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Ok",
"schema": {
"$ref": "#/definitions/Client"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/Error"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": { "delete": {
"security": [ "security": [
{ {

View File

@ -139,6 +139,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
client.POST("", clientHandler.CreateClient) client.POST("", clientHandler.CreateClient)
client.DELETE("/:id", clientHandler.DeleteClient) client.DELETE("/:id", clientHandler.DeleteClient)
client.PUT("/:id", clientHandler.UpdateClient)
} }
message := clientAuth.Group("/message") message := clientAuth.Group("/message")

View File

@ -19,6 +19,13 @@ export class ClientStore extends BaseStore<IClient> {
.then(() => this.snack('Client deleted')); .then(() => this.snack('Client deleted'));
} }
@action
public update = async (id: number, name: string): Promise<void> => {
await axios.put(`${config.get('url')}client/${id}`, {name});
await this.refresh();
this.snack('Client updated');
};
@action @action
public createNoNotifcation = async (name: string): Promise<IClient> => { public createNoNotifcation = async (name: string): Promise<IClient> => {
const client = await axios.post(`${config.get('url')}client`, {name}); const client = await axios.post(`${config.get('url')}client`, {name});

View File

@ -7,11 +7,13 @@ import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead'; import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow'; import TableRow from '@material-ui/core/TableRow';
import Delete from '@material-ui/icons/Delete'; import Delete from '@material-ui/icons/Delete';
import Edit from '@material-ui/icons/Edit';
import React, {Component, SFC} from 'react'; import React, {Component, SFC} from 'react';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import ToggleVisibility from '../common/ToggleVisibility'; import ToggleVisibility from '../common/ToggleVisibility';
import AddClientDialog from './AddClientDialog'; import AddClientDialog from './AddClientDialog';
import UpdateDialog from './UpdateClientDialog';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {observable} from 'mobx'; import {observable} from 'mobx';
import {inject, Stores} from '../inject'; import {inject, Stores} from '../inject';
@ -22,12 +24,15 @@ class Clients extends Component<Stores<'clientStore'>> {
private showDialog = false; private showDialog = false;
@observable @observable
private deleteId: false | number = false; private deleteId: false | number = false;
@observable
private updateId: false | number = false;
public componentDidMount = () => this.props.clientStore.refresh(); public componentDidMount = () => this.props.clientStore.refresh();
public render() { public render() {
const { const {
deleteId, deleteId,
updateId,
showDialog, showDialog,
props: {clientStore}, props: {clientStore},
} = this; } = this;
@ -47,6 +52,7 @@ class Clients extends Component<Stores<'clientStore'>> {
<TableCell>Name</TableCell> <TableCell>Name</TableCell>
<TableCell style={{width: 200}}>token</TableCell> <TableCell style={{width: 200}}>token</TableCell>
<TableCell /> <TableCell />
<TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -56,6 +62,7 @@ class Clients extends Component<Stores<'clientStore'>> {
key={client.id} key={client.id}
name={client.name} name={client.name}
value={client.token} value={client.token}
fEdit={() => (this.updateId = client.id)}
fDelete={() => (this.deleteId = client.id)} fDelete={() => (this.deleteId = client.id)}
/> />
); );
@ -70,6 +77,13 @@ class Clients extends Component<Stores<'clientStore'>> {
fOnSubmit={clientStore.create} fOnSubmit={clientStore.create}
/> />
)} )}
{updateId !== false && (
<UpdateDialog
fClose={() => (this.updateId = false)}
fOnSubmit={(name) => clientStore.update(updateId, name)}
initialName={clientStore.getByID(updateId).name}
/>
)}
{deleteId !== false && ( {deleteId !== false && (
<ConfirmDialog <ConfirmDialog
title="Confirm Delete" title="Confirm Delete"
@ -86,10 +100,11 @@ class Clients extends Component<Stores<'clientStore'>> {
interface IRowProps { interface IRowProps {
name: string; name: string;
value: string; value: string;
fEdit: VoidFunction;
fDelete: VoidFunction; fDelete: VoidFunction;
} }
const Row: SFC<IRowProps> = ({name, value, fDelete}) => ( const Row: SFC<IRowProps> = ({name, value, fEdit, fDelete}) => (
<TableRow> <TableRow>
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell> <TableCell>
@ -99,6 +114,11 @@ const Row: SFC<IRowProps> = ({name, value, fDelete}) => (
/> />
</TableCell> </TableCell>
<TableCell numeric padding="none"> <TableCell numeric padding="none">
<IconButton onClick={fEdit} className="edit">
<Edit />
</IconButton>
</TableCell>
<TableCell>
<IconButton onClick={fDelete} className="delete"> <IconButton onClick={fDelete} className="delete">
<Delete /> <Delete />
</IconButton> </IconButton>

View File

@ -0,0 +1,83 @@
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
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 React, {Component} from 'react';
interface IProps {
fClose: VoidFunction;
fOnSubmit: (name: string) => void;
initialName: string;
}
interface IState {
name: string;
}
export default class UpdateDialog extends Component<IProps, IState> {
public state = {name: ''};
public componentWillMount() {
this.setState({name: this.props.initialName});
}
public render() {
const {fClose, fOnSubmit} = this.props;
const {name} = this.state;
const submitEnabled = this.state.name.length !== 0;
const submitAndClose = () => {
fOnSubmit(name);
fClose();
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="client-dialog">
<DialogTitle id="form-dialog-title">Update a Client</DialogTitle>
<DialogContent>
<DialogContentText>
A client manages messages, clients, applications and users (with admin
permissions).
</DialogContentText>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="text"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="update"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="raised">
Update
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
const state = {};
state[propertyName] = event.target.value;
this.setState(state);
}
}

View File

@ -1,6 +1,6 @@
import {Page} from 'puppeteer'; import {Page} from 'puppeteer';
import {newTest, GotifyTest} from './setup'; import {newTest, GotifyTest} from './setup';
import {count, innerText, waitForExists, waitToDisappear} from './utils'; import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';
import * as auth from './authentication'; import * as auth from './authentication';
import * as selector from './selector'; import * as selector from './selector';
@ -17,9 +17,30 @@ afterAll(async () => await gotify.close());
enum Col { enum Col {
Name = 1, Name = 1,
Token = 2, Token = 2,
Delete = 3, Edit = 3,
Delete = 4,
} }
const hasClient = (name: string, row: number): (() => Promise<void>) => {
return async () => {
expect(await innerText(page, $table.cell(row, Col.Name))).toBe(name);
};
};
export const updateClient = (id: number, data: {name?: string}): (() => Promise<void>) => {
return async () => {
await page.click($table.cell(id, Col.Edit, '.edit'));
await page.waitForSelector($dialog.selector());
if (data.name) {
const nameSelector = $dialog.input('.name');
await clearField(page, nameSelector);
await page.type(nameSelector, data.name);
}
await page.click($dialog.button('.update'));
await waitToDisappear(page, $dialog.selector());
};
};
const $table = selector.table('#client-table'); const $table = selector.table('#client-table');
const $dialog = selector.form('#client-dialog'); const $dialog = selector.form('#client-dialog');
@ -56,6 +77,8 @@ describe('Client', () => {
expect(await innerText(page, $table.cell(2, Col.Name))).toBe('phone'); expect(await innerText(page, $table.cell(2, Col.Name))).toBe('phone');
expect(await innerText(page, $table.cell(3, Col.Name))).toBe('desktop app'); expect(await innerText(page, $table.cell(3, Col.Name))).toBe('desktop app');
}); });
it('updates client', updateClient(1, {name: 'firefox'}));
it('has updated client name', hasClient('firefox', 1));
it('shows token', async () => { it('shows token', async () => {
await page.click($table.cell(3, Col.Token, '.toggle-visibility')); await page.click($table.cell(3, Col.Token, '.toggle-visibility'));
expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy(); expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy();