Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions api/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ type ApplicationParams struct {
//
// example: 5
DefaultPriority int `form:"defaultPriority" query:"defaultPriority" json:"defaultPriority"`
// The order in which this application should appear in the UI. Defaults to 0.
//
// example: 7
SortOrder int `form:"sortOrder" query:"sortOrder" json:"sortOrder"`
}

// CreateApplication creates an application and returns the access token.
Expand Down Expand Up @@ -90,6 +94,7 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
Name: applicationParams.Name,
Description: applicationParams.Description,
DefaultPriority: applicationParams.DefaultPriority,
SortOrder: applicationParams.SortOrder,
Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists),
UserID: auth.GetUserID(ctx),
Internal: false,
Expand Down Expand Up @@ -251,6 +256,7 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
app.Description = applicationParams.Description
app.Name = applicationParams.Name
app.DefaultPriority = applicationParams.DefaultPriority
app.SortOrder = applicationParams.SortOrder

if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
return
Expand Down Expand Up @@ -466,3 +472,99 @@ func ValidApplicationImageExt(ext string) bool {
return false
}
}

// ApplicationOrderParams represents the new order for applications
type ApplicationOrderParams struct {
// Array of application IDs in the desired order
// required: true
// example: [3, 1, 5, 2, 4]
ApplicationIDs []uint `json:"applicationIds" binding:"required"`
}

// ReorderApplications updates the sort order of multiple applications based on their position in the array.
// swagger:operation PUT /application/reorder application reorderApplications
//
// Reorder applications.
//
// ---
// consumes: [application/json]
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
// parameters:
// - name: body
// in: body
// description: ordered list of application IDs
// required: true
// schema:
// $ref: "#/definitions/ApplicationOrderParams"
// responses:
// 200:
// description: Ok
// schema:
// type: array
// items:
// $ref: "#/definitions/Application"
// 400:
// description: Bad Request
// schema:
// $ref: "#/definitions/Error"
// 401:
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
// 403:
// description: Forbidden
// schema:
// $ref: "#/definitions/Error"
// 500:
// description: Server Error
// schema:
// $ref: "#/definitions/Error"
func (a *ApplicationAPI) ReorderApplications(ctx *gin.Context) {
userID := auth.GetUserID(ctx)

orderParams := ApplicationOrderParams{}
if err := ctx.Bind(&orderParams); err != nil {
ctx.AbortWithError(400, err)
return
}

if len(orderParams.ApplicationIDs) == 0 {
ctx.AbortWithError(400, errors.New("applicationIds array cannot be empty"))
return
}

userApps, err := a.DB.GetApplicationsByUser(userID)
if success := successOrAbort(ctx, 500, err); !success {
return
}

userAppMap := make(map[uint]bool)
for _, app := range userApps {
userAppMap[app.ID] = true
}

for _, appID := range orderParams.ApplicationIDs {
if !userAppMap[appID] {
ctx.AbortWithError(403, fmt.Errorf("application with id %d does not belong to user", appID))
return
}
}

updatedApps := make([]*model.Application, 0, len(orderParams.ApplicationIDs))
for i, appID := range orderParams.ApplicationIDs {
app, err := a.DB.GetApplicationByID(appID)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if app != nil {
app.SortOrder = i
if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future reference (after the strategy is settled), please only update the column you need, especially if the operation is not atomic (you are repeating a lot of read/writes in a loop), can happen in a "rapid fire" fasion (a user typically wants to DND a lot at once) and touch critical fields (you overwrote all fields)

What we don't want to happen is to have a stalled DB query (which can happen for a lot of reasons, a contending lock or just packet loss) cause transactions to race with transactions for the next DND request, which can lead to incoherent state or even data corruption.

return
}
updatedApps = append(updatedApps, withResolvedImage(app))
}
}

ctx.JSON(200, updatedApps)
}
3 changes: 2 additions & 1 deletion api/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation()
Image: "asd",
Internal: true,
LastUsed: nil,
SortOrder: 7,
}
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null}`)
test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true, "defaultPriority":0, "lastUsed":null, "sortOrder":7}`)
}

func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {
Expand Down
2 changes: 1 addition & 1 deletion database/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (d *GormDatabase) DeleteApplicationByID(id uint) error {
// GetApplicationsByUser returns all applications from a user.
func (d *GormDatabase) GetApplicationsByUser(userID uint) ([]*model.Application, error) {
var apps []*model.Application
err := d.DB.Where("user_id = ?", userID).Order("id ASC").Find(&apps).Error
err := d.DB.Where("user_id = ?", userID).Order("sort_order ASC").Find(&apps).Error
if err == gorm.ErrRecordNotFound {
err = nil
}
Expand Down
4 changes: 4 additions & 0 deletions model/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,8 @@ type Application struct {
// read only: true
// example: 2019-01-01T00:00:00Z
LastUsed *time.Time `json:"lastUsed"`
// The order in which the application should appear in the UI.
//
// example: 7
SortOrder int `json:"sortOrder" query:"sortOrder" form: "sortOrder"`
}
2 changes: 2 additions & 0 deletions router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co

app.POST("", applicationHandler.CreateApplication)

app.PUT("/reorder", applicationHandler.ReorderApplications)

app.POST("/:id/image", applicationHandler.UploadApplicationImage)

app.DELETE("/:id/image", applicationHandler.RemoveApplicationImage)
Expand Down
3 changes: 3 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"homepage": ".",
"proxy": "http://localhost:80",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
Expand Down
13 changes: 11 additions & 2 deletions ui/src/application/AddApplicationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@ import React, {useState} from 'react';

interface IProps {
fClose: VoidFunction;
fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;
fOnSubmit: (name: string, description: string, defaultPriority: number, sortOrder: number) => Promise<void>;
}

export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [defaultPriority, setDefaultPriority] = useState(0);
const [sortOrder, setSortOrder] = useState(0);

const submitEnabled = name.length !== 0;
const submitAndClose = async () => {
await fOnSubmit(name, description, defaultPriority);
await fOnSubmit(name, description, defaultPriority, sortOrder);
fClose();
};

Expand Down Expand Up @@ -57,6 +58,14 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {
onChange={(value) => setDefaultPriority(value)}
fullWidth
/>
<NumberField
margin="dense"
className="sortOrder"
label="Sort Order"
value={sortOrder}
onChange={(value) => setSortOrder(value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
Expand Down
16 changes: 14 additions & 2 deletions ui/src/application/AppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ export class AppStore extends BaseStore<IApplication> {
id: number,
name: string,
description: string,
defaultPriority: number
defaultPriority: number,
sortOrder: number
): Promise<void> => {
await axios.put(`${config.get('url')}application/${id}`, {
name,
description,
defaultPriority,
sortOrder,
});
await this.refresh();
this.snack('Application updated');
Expand All @@ -57,17 +59,27 @@ export class AppStore extends BaseStore<IApplication> {
public create = async (
name: string,
description: string,
defaultPriority: number
defaultPriority: number,
sortOrder: number
): Promise<void> => {
await axios.post(`${config.get('url')}application`, {
name,
description,
defaultPriority,
sortOrder,
});
await this.refresh();
this.snack('Application created');
};

public reorder = async (applicationIds: number[]): Promise<void> => {
await axios.put(`${config.get('url')}application/reorder`, {
applicationIds,
});
await this.refresh();
this.snack('Applications reordered');
};

public getName = (id: number): string => {
const app = this.getByIDOrUndefined(id);
return id === -1 ? 'All Messages' : app !== undefined ? app.name : 'unknown';
Expand Down
Loading