Skip to content
This repository has been archived by the owner on Feb 28, 2024. It is now read-only.

Commit

Permalink
Merge pull request #226 from CreatekIO/feature/#144-assignee-picker
Browse files Browse the repository at this point in the history
Feature/#144 assignee picker
  • Loading branch information
rjpaskin authored Nov 26, 2021
2 parents 20d3b38 + b7cf52a commit 9fd035f
Show file tree
Hide file tree
Showing 22 changed files with 901 additions and 143 deletions.
8 changes: 0 additions & 8 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,4 @@ def feature_in_params?(name)

@param_features.include?(name.to_s)
end

def json_array_from(records)
Jbuilder.encode do |json|
json.array! records do |record|
json.merge! record.to_builder.attributes!
end
end
end
end
21 changes: 21 additions & 0 deletions app/controllers/assignees_controller.rb
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
2 changes: 1 addition & 1 deletion app/controllers/labellings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def update
)

if changeset.save
render json: json_array_from(@ticket.display_labels.reload), status: :ok
render json: LabelBlueprint.render(@ticket.display_labels.reload), status: :ok
else
render json: { errors: changeset.error_messages }, status: :unprocessable_entity
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/labels_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ class LabelsController < AuthenticatedController
load_and_authorize_resource :repo

def index
render json: json_array_from(@repo.display_labels)
render json: LabelBlueprint.render(@repo.display_labels)
end
end
25 changes: 25 additions & 0 deletions app/controllers/ticket_assignments_controller.rb
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
3 changes: 2 additions & 1 deletion app/models/repo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ class Repo < ApplicationRecord

scope :auto_deployable, -> { where(auto_deploy: true) }

octokit_methods :branches, :compare, prefix_with: :slug
octokit_methods :branches, :compare, :repo_assignees, prefix_with: :slug
alias_method :remote_assignees, :octokit_repo_assignees

URL_TEMPLATE = 'https://github.com/%s'.freeze
DEFAULT_DEPLOYMENT_BRANCH = 'master'.freeze
Expand Down
125 changes: 125 additions & 0 deletions app/packs/components/AssigneePicker.jsx
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);
106 changes: 106 additions & 0 deletions app/packs/components/LabelPicker.jsx
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);
Loading

0 comments on commit 9fd035f

Please sign in to comment.