This repository has been archived by the owner on Feb 28, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
Feature/#144 assignee picker
- Loading branch information
Showing
22 changed files
with
901 additions
and
143 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
class AssigneesController < AuthenticatedController | ||
load_and_authorize_resource :repo | ||
|
||
def index | ||
render json: remote_assignees | ||
|
||
response.headers['Cache-Control'] = @repo.octokit.last_response.headers[:cache_control] | ||
end | ||
|
||
private | ||
|
||
def octokit_client_options | ||
{ access_token: current_user_github_token } | ||
end | ||
|
||
def remote_assignees | ||
@repo.remote_assignees.map do |user| | ||
{ remote_id: user.id, username: user.login } | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
class TicketAssignmentsController < AuthenticatedController | ||
load_and_authorize_resource :board | ||
load_and_authorize_resource :board_ticket, through: :board, id_param: :ticket_id | ||
load_and_authorize_resource :ticket, through: :board_ticket, singleton: true | ||
|
||
def update | ||
changeset = TicketAssigneeChangeset.new( | ||
ticket: @ticket, | ||
changes: assignment_params, | ||
token: current_user_github_token | ||
) | ||
|
||
if changeset.save | ||
render json: TicketAssignmentBlueprint.render(@ticket.assignments.reload), status: :ok | ||
else | ||
render json: { errors: changeset.error_messages }, status: :unprocessable_entity | ||
end | ||
end | ||
|
||
private | ||
|
||
def assignment_params | ||
params.require(:assignment).permit(add: [], remove: []) | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import React, { useEffect, useCallback } from "react"; | ||
import { connect } from "react-redux"; | ||
import { useNavigate } from "@reach/router"; | ||
import classNames from "classnames"; | ||
import { toast } from "react-toastify"; | ||
|
||
import Avatar from "./Avatar"; | ||
import Picker from "./Picker"; | ||
import { fetchAssigneesForRepo } from "../slices/repos"; | ||
import { updateAssigneesForTicket } from "../slices/board_tickets"; | ||
|
||
const AssigneeAvatar = ({ isSelected, item: { username }}) => ( | ||
<Avatar | ||
username={username} | ||
size="mini" | ||
className={classNames("mr-2", { "ml-5": !isSelected })} | ||
/> | ||
); | ||
|
||
const AssigneePicker = ({ | ||
boardTicketId, | ||
currentAssigneeIds, | ||
repoAssignees, | ||
repoId, | ||
backPath, | ||
enableAfter, | ||
fetchAssigneesForRepo, | ||
updateAssigneesForTicket | ||
}) => { | ||
useEffect(() => { fetchAssigneesForRepo(repoId) }, [repoId]); | ||
|
||
const navigate = useNavigate(); | ||
|
||
const onSubmit = useCallback(selectedIds => { | ||
const add = selectedIds | ||
.filter(id => !currentAssigneeIds.includes(id)) | ||
.map(id => repoAssignees.find(({ id: assigneeId }) => assigneeId === id)) | ||
.filter(Boolean) | ||
.map(({ id, username }) => ({ remote_id: id, username })); | ||
|
||
const remove = currentAssigneeIds | ||
.filter(id => !selectedIds.includes(id)) | ||
.map(id => repoAssignees.find(({ id: assigneeId }) => assigneeId === id)) | ||
.filter(Boolean) | ||
.map(({ id, username }) => ({ remote_id: id, username })); | ||
|
||
updateAssigneesForTicket({ | ||
id: boardTicketId, | ||
add, | ||
remove | ||
}).then(({ meta, payload, error }) => { | ||
if (error && !meta.condition) { | ||
if (meta.rejectedWithValue) { | ||
payload.forEach(message => toast.error(message)); | ||
} else { | ||
toast.error("Failed to update assignees"); | ||
} | ||
} else { | ||
toast.success("Assignees updated"); | ||
} | ||
}); | ||
|
||
navigate(`../${backPath}`); | ||
}, [boardTicketId, currentAssigneeIds, backPath]); | ||
|
||
return ( | ||
<Picker | ||
label="Edit assignees" | ||
placeholder="Filter users" | ||
availableItems={repoAssignees} | ||
currentIds={currentAssigneeIds} | ||
backPath={backPath} | ||
nameProp="username" | ||
icon={AssigneeAvatar} | ||
onSubmit={onSubmit} | ||
enableAfter={enableAfter} | ||
itemClassName="font-bold text-sm" | ||
/> | ||
) | ||
}; | ||
|
||
const EMPTY = []; | ||
|
||
const mapStateToProps = (_, { boardTicketId }) => ({ | ||
entities: { boardTickets, repos, tickets } | ||
}) => { | ||
const boardTicket = boardTickets[boardTicketId]; | ||
const ticket = boardTicket && tickets[boardTicket.ticket]; | ||
|
||
const currentAssigneeIds = (boardTicket && boardTicket.assignees) | ||
? boardTicket.assignees.map(({ remote_id }) => remote_id) | ||
: EMPTY; | ||
|
||
const repoId = ticket && ticket.repo; | ||
const { availableAssignees = EMPTY } = (repoId && repos[repoId]) || {}; | ||
|
||
const repoAssignees = availableAssignees.map(({ remote_id: id, username }) => ({ | ||
id, | ||
username, | ||
active: currentAssigneeIds.includes(id), | ||
isCurrentUser: username.localeCompare(flightPlanConfig.currentUser.username) === 0 | ||
})); | ||
|
||
repoAssignees.sort((a, b) => { | ||
if (a.active != b.active) { | ||
// put the active item first - if we get here it's guaranteed | ||
// that if `a` is active, `b` is not, and vice-versa | ||
return a.active ? -1 : 1; | ||
} else if (a.isCurrentUser != b.isCurrentUser) { | ||
// put the current user above other users | ||
return a.isCurrentUser ? -1 : 1; | ||
} else { | ||
// both are either active or inactive, so sort by username | ||
// - should be case-insensitive by default | ||
return a.username.localeCompare(b.username); | ||
} | ||
}); | ||
|
||
return { currentAssigneeIds, repoAssignees, repoId }; | ||
}; | ||
|
||
export default connect( | ||
mapStateToProps, | ||
{ fetchAssigneesForRepo, updateAssigneesForTicket } | ||
)(AssigneePicker); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import React, { useEffect, useCallback } from "react"; | ||
import { connect } from "react-redux"; | ||
import { useNavigate } from "@reach/router"; | ||
import classNames from "classnames"; | ||
import { toast } from "react-toastify"; | ||
|
||
import Picker from "./Picker"; | ||
import { fetchLabelsForRepo } from "../slices/labels"; | ||
import { updateLabelsForTicket } from "../slices/board_tickets"; | ||
|
||
const ColourIcon = ({ isSelected, item: { colour }}) => ( | ||
<span | ||
className={classNames( | ||
"rounded-full w-4 h-4 inline-block bg-gray-100 mr-2", | ||
{ "ml-5": !isSelected } | ||
)} | ||
style={{ backgroundColor: `#${colour}` }} | ||
/> | ||
); | ||
|
||
const LabelPicker = ({ | ||
boardTicketId, | ||
currentLabelIds, | ||
repoLabels, | ||
repoId, | ||
backPath, | ||
enableAfter, | ||
fetchLabelsForRepo, | ||
updateLabelsForTicket | ||
}) => { | ||
useEffect(() => { fetchLabelsForRepo(repoId) }, [repoId]); | ||
|
||
const navigate = useNavigate(); | ||
|
||
const onSubmit = useCallback(selectedIds => { | ||
updateLabelsForTicket({ | ||
id: boardTicketId, | ||
add: selectedIds.filter(id => !currentLabelIds.includes(id)), | ||
remove: currentLabelIds.filter(id => !selectedIds.includes(id)) | ||
}).then(({ meta, payload, error }) => { | ||
if (error && !meta.condition) { | ||
if (meta.rejectedWithValue) { | ||
payload.forEach(message => toast.error(message)); | ||
} else { | ||
toast.error("Failed to update labels"); | ||
} | ||
} else { | ||
toast.success("Labels updated"); | ||
} | ||
}); | ||
|
||
navigate(`../${backPath}`); | ||
}, [boardTicketId, currentLabelIds, backPath]); | ||
|
||
return ( | ||
<Picker | ||
label="Edit labels" | ||
placeholder="Filter labels" | ||
availableItems={repoLabels} | ||
currentIds={currentLabelIds} | ||
backPath={backPath} | ||
nameProp="name" | ||
icon={ColourIcon} | ||
onSubmit={onSubmit} | ||
enableAfter={enableAfter} | ||
/> | ||
) | ||
}; | ||
|
||
const EMPTY = []; | ||
|
||
const mapStateToProps = (_, { boardTicketId }) => ({ | ||
entities: { boardTickets, labels, tickets } | ||
}) => { | ||
const boardTicket = boardTickets[boardTicketId]; | ||
const ticket = boardTicket && tickets[boardTicket.ticket]; | ||
|
||
const currentLabelIds = boardTicket ? boardTicket.labels : EMPTY; | ||
|
||
const repoId = ticket && ticket.repo; | ||
const repoLabels = repoId | ||
? Object.values(labels).filter(({ repo }) => repo === repoId) | ||
: EMPTY; | ||
|
||
repoLabels.sort((a, b) => { | ||
const aIsActive = currentLabelIds.includes(a.id); | ||
const bIsActive = currentLabelIds.includes(b.id); | ||
|
||
if (aIsActive != bIsActive) { | ||
// put the active item first - if we get here it's guaranteed | ||
// that if `a` is active, `b` is not, and vice-versa | ||
return aIsActive ? -1 : 1; | ||
} else { | ||
// both are either active or inactive, so sort by name | ||
// - should be case-insensitive by default | ||
return a.name.localeCompare(b.name); | ||
} | ||
}); | ||
|
||
return { currentLabelIds, repoLabels, repoId }; | ||
}; | ||
|
||
export default connect( | ||
mapStateToProps, | ||
{ fetchLabelsForRepo, updateLabelsForTicket } | ||
)(LabelPicker); |
Oops, something went wrong.