Add update client api and dialog (#164)
This commit is contained in:
parent
efcf4ad13d
commit
e32359ed15
|
|
@ -15,6 +15,7 @@ type ClientDatabase interface {
|
|||
GetClientByID(id uint) *model.Client
|
||||
GetClientsByUser(userID uint) []*model.Client
|
||||
DeleteClientByID(id uint) error
|
||||
UpdateClient(client *model.Client) error
|
||||
}
|
||||
|
||||
// The ClientAPI provides handlers for managing clients and applications.
|
||||
|
|
@ -24,6 +25,65 @@ type ClientAPI struct {
|
|||
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.
|
||||
// swagger:operation POST /client client createClient
|
||||
//
|
||||
|
|
|
|||
|
|
@ -168,6 +168,40 @@ func (s *ClientSuite) Test_DeleteClient() {
|
|||
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) {
|
||||
s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData))
|
||||
s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
|
|
|||
|
|
@ -38,3 +38,8 @@ func (d *GormDatabase) GetClientsByUser(userID uint) []*model.Client {
|
|||
func (d *GormDatabase) DeleteClientByID(id uint) 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ func (s *DatabaseSuite) TestClient() {
|
|||
newClient = s.db.GetClientByToken(client.Token)
|
||||
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)
|
||||
|
||||
clients = s.db.GetClientsByUser(user.ID)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
//
|
||||
// Schemes: http, https
|
||||
// Host: localhost
|
||||
// Version: 2.0.0
|
||||
// Version: 2.0.1
|
||||
// License: MIT https://github.com/gotify/server/blob/master/LICENSE
|
||||
//
|
||||
// Consumes:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
"name": "MIT",
|
||||
"url": "https://github.com/gotify/server/blob/master/LICENSE"
|
||||
},
|
||||
"version": "2.0.0"
|
||||
"version": "2.0.1"
|
||||
},
|
||||
"host": "localhost",
|
||||
"paths": {
|
||||
|
|
@ -599,6 +599,80 @@
|
|||
}
|
||||
},
|
||||
"/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": {
|
||||
"security": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -139,6 +139,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
|
|||
client.POST("", clientHandler.CreateClient)
|
||||
|
||||
client.DELETE("/:id", clientHandler.DeleteClient)
|
||||
|
||||
client.PUT("/:id", clientHandler.UpdateClient)
|
||||
}
|
||||
|
||||
message := clientAuth.Group("/message")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ export class ClientStore extends BaseStore<IClient> {
|
|||
.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
|
||||
public createNoNotifcation = async (name: string): Promise<IClient> => {
|
||||
const client = await axios.post(`${config.get('url')}client`, {name});
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ import TableCell from '@material-ui/core/TableCell';
|
|||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Delete from '@material-ui/icons/Delete';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import React, {Component, SFC} from 'react';
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
import DefaultPage from '../common/DefaultPage';
|
||||
import ToggleVisibility from '../common/ToggleVisibility';
|
||||
import AddClientDialog from './AddClientDialog';
|
||||
import UpdateDialog from './UpdateClientDialog';
|
||||
import {observer} from 'mobx-react';
|
||||
import {observable} from 'mobx';
|
||||
import {inject, Stores} from '../inject';
|
||||
|
|
@ -22,12 +24,15 @@ class Clients extends Component<Stores<'clientStore'>> {
|
|||
private showDialog = false;
|
||||
@observable
|
||||
private deleteId: false | number = false;
|
||||
@observable
|
||||
private updateId: false | number = false;
|
||||
|
||||
public componentDidMount = () => this.props.clientStore.refresh();
|
||||
|
||||
public render() {
|
||||
const {
|
||||
deleteId,
|
||||
updateId,
|
||||
showDialog,
|
||||
props: {clientStore},
|
||||
} = this;
|
||||
|
|
@ -47,6 +52,7 @@ class Clients extends Component<Stores<'clientStore'>> {
|
|||
<TableCell>Name</TableCell>
|
||||
<TableCell style={{width: 200}}>token</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
@ -56,6 +62,7 @@ class Clients extends Component<Stores<'clientStore'>> {
|
|||
key={client.id}
|
||||
name={client.name}
|
||||
value={client.token}
|
||||
fEdit={() => (this.updateId = client.id)}
|
||||
fDelete={() => (this.deleteId = client.id)}
|
||||
/>
|
||||
);
|
||||
|
|
@ -70,6 +77,13 @@ class Clients extends Component<Stores<'clientStore'>> {
|
|||
fOnSubmit={clientStore.create}
|
||||
/>
|
||||
)}
|
||||
{updateId !== false && (
|
||||
<UpdateDialog
|
||||
fClose={() => (this.updateId = false)}
|
||||
fOnSubmit={(name) => clientStore.update(updateId, name)}
|
||||
initialName={clientStore.getByID(updateId).name}
|
||||
/>
|
||||
)}
|
||||
{deleteId !== false && (
|
||||
<ConfirmDialog
|
||||
title="Confirm Delete"
|
||||
|
|
@ -86,10 +100,11 @@ class Clients extends Component<Stores<'clientStore'>> {
|
|||
interface IRowProps {
|
||||
name: string;
|
||||
value: string;
|
||||
fEdit: VoidFunction;
|
||||
fDelete: VoidFunction;
|
||||
}
|
||||
|
||||
const Row: SFC<IRowProps> = ({name, value, fDelete}) => (
|
||||
const Row: SFC<IRowProps> = ({name, value, fEdit, fDelete}) => (
|
||||
<TableRow>
|
||||
<TableCell>{name}</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -99,6 +114,11 @@ const Row: SFC<IRowProps> = ({name, value, fDelete}) => (
|
|||
/>
|
||||
</TableCell>
|
||||
<TableCell numeric padding="none">
|
||||
<IconButton onClick={fEdit} className="edit">
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton onClick={fDelete} className="delete">
|
||||
<Delete />
|
||||
</IconButton>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import {Page} from 'puppeteer';
|
||||
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 selector from './selector';
|
||||
|
|
@ -17,9 +17,30 @@ afterAll(async () => await gotify.close());
|
|||
enum Col {
|
||||
Name = 1,
|
||||
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 $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(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 () => {
|
||||
await page.click($table.cell(3, Col.Token, '.toggle-visibility'));
|
||||
expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy();
|
||||
|
|
|
|||
Loading…
Reference in New Issue