Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Advanced search #527

Merged
merged 13 commits into from
Aug 2, 2022
2 changes: 2 additions & 0 deletions backend/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ func Setup() *fiber.App {

app.Post("/api/priority_entries", postPriorityEntriesHandler)

app.Post("/api/search", issueSearchHandler)

// 404 Handler
app.Use(func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNotFound) // => 404 "Not Found"
Expand Down
41 changes: 41 additions & 0 deletions backend/api/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,28 @@ func Test_Handlers(t *testing.T) {

issueActsResp, _ := json.Marshal(issueAct)

issueSearchResponse := api.IssueSearchResponse{
Results: []api.IssueWithTitle{
{
Id: 1,
Title: "test issue",
},
},
}

searchResponse, _ := json.Marshal(issueSearchResponse)
var err error

// Create a fake Redmine server to which redmine requests will be forwarded
fakeRedmine := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch endpoint := r.URL.Path; endpoint {
case "/search.json":
if r.Method == "GET" {
w.WriteHeader(fiber.StatusOK)
_, err = w.Write(searchResponse)
}
w.WriteHeader(fiber.StatusMethodNotAllowed)
_, err = w.Write(nil)
case "/my/account.json":
_, err = w.Write(userResponse)
case "/time_entries.json":
Expand Down Expand Up @@ -362,6 +379,30 @@ func Test_Handlers(t *testing.T) {
useSessionHeader: false,
statusCode: fiber.StatusUnauthorized,
},
{
name: "search POST",
method: "POST",
endpoint: "/api/search",
testRedmine: fakeRedmine,
useSessionHeader: true,
statusCode: fiber.StatusOK,
},
{
name: "search POST 401",
method: "POST",
endpoint: "/api/search",
testRedmine: fakeRedmine,
useSessionHeader: false,
statusCode: fiber.StatusUnauthorized,
},
{
name: "search GET",
method: "GET",
endpoint: "/api/search",
testRedmine: fakeRedmine,
useSessionHeader: true,
statusCode: fiber.StatusNotFound,
},
{
name: "Logout",
method: "POST",
Expand Down
67 changes: 67 additions & 0 deletions backend/api/issueSearchHandler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package api

import (
"encoding/json"
"fmt"
"strings"
"urdr-api/internal/config"

"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/proxy"
)

// loginHandler godoc
// @Summary Search for open Redmine issues
// @Description Search for issues passing using a search wuery
// @Accept json
// @Produce json
// @Success 200 {object} []redmine.Issue
// @Failure 422 {string} error "Unprocessable Entity"
// @Failure 500 {string} error "Internal Server Error"
// @Router /api/search [post]
func issueSearchHandler(c *fiber.Ctx) error {

if ok, err := prepareRedmineRequest(c); !ok {
return err
}

redmineURL := fmt.Sprintf("%s/search.json?%s",
config.Config.Redmine.URL, c.Request().URI().QueryString())

// Redmine wants a GET request here, not a POST.
c.Request().Header.SetMethod(fiber.MethodGet)

if err := proxy.Do(c, redmineURL); err != nil {
return err
}

searchResponse := struct {
Results []struct {
Id int `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
} `json:"results"`
}{}

if err := json.Unmarshal(c.Response().Body(), &searchResponse); err != nil {
c.Response().Reset()
return c.SendStatus(fiber.StatusUnprocessableEntity)
}

var foundIssues []Issue

for _, issue := range searchResponse.Results {
if issue.Type == "issue" && strings.Contains(issue.Title, "):") {
issueSubject := strings.TrimSpace(strings.Split(issue.Title, "):")[1])
issue := Issue{
Id: issue.Id,
Subject: issueSubject,
}
foundIssues = append(foundIssues, issue)
}
}

return c.JSON(IssuesResponse{
Issues: foundIssues,
})
}
13 changes: 13 additions & 0 deletions backend/api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ type Issue struct {
Subject string `json:"subject"`
}

type IssueWithTitle struct {
Id int `json:"id"`
Title string `json:"title"`
}

type IssuesResponse struct {
Issues []Issue `json:"issues"`
}

type Activity struct {
Id int `json:"id"`
Name string `json:"name"`
Expand All @@ -25,3 +34,7 @@ type PriorityEntry struct {
CustomName string `json:"custom_name"`
IsHidden bool `json:"is_hidden"`
}

type IssueSearchResponse struct {
Results []IssueWithTitle `json:"results"`
}
140 changes: 117 additions & 23 deletions frontend/src/components/QuickAdd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import plus from "../icons/plus.svg";
import x from "../icons/x.svg";
import check from "../icons/check.svg";
import { AuthContext } from "../components/AuthProvider";
import { PUBLIC_API_URL, headers } from "../utils";
import * as ReactDOM from "react-dom";

export const QuickAdd = ({
addIssueActivity,
Expand All @@ -14,7 +16,12 @@ export const QuickAdd = ({
const [activities, setActivities] = useState<IdName[]>([]);
const [issue, setIssue] = useState<Issue>(null);
const [activity, setActivity] = useState<IdName>();
const [search, setSearch] = useState("");
const [search, setSearch] = useState({
text: "",
suggestions: [],
});
const [isAutoCompleteVisible, setIsAutoCompleteVisible] = useState(false);

const debouncedSearch = useDebounce(search, 500);
const context = React.useContext(AuthContext);

Expand Down Expand Up @@ -43,32 +50,94 @@ export const QuickAdd = ({
React.useEffect(
() => {
let didCancel = false;
const searchIssue = async () => {
if (debouncedSearch) {
const endpoint = `/api/issues?status_id=*&issue_id=${search}`;
let result: { issues: Issue[] } = await getApiEndpoint(
endpoint,
context
);
if (!didCancel) {
if (result.issues.length > 0) {
const issue: Issue = {
id: result.issues[0].id,
subject: result.issues[0].subject,
};
setIssue(issue);
const searchSuggestions = async () => {
if (search.text === "") {
setIsAutoCompleteVisible(false);
}
if (debouncedSearch && search.text) {
let res: { issues: Issue[] };
let candidateIssues: Issue[];

if (Number.isInteger(Number(search.text))) {
const endpoint = `/api/issues?status_id=*&issue_id=${search.text}`;
res = await getApiEndpoint(endpoint, context);
} else {
res = await searchIssues(search.text);
}
if (!didCancel && res.issues) {
if (res.issues.length > 0) {
if (res.issues.length === 1) {
candidateIssues = [res.issues[0]];
} else {
candidateIssues = res.issues;
}
setSearch({ text: undefined, suggestions: candidateIssues });
setIsAutoCompleteVisible(true);
} else {
setIsAutoCompleteVisible(false);
}
} else {
setIsAutoCompleteVisible(false);
}
}
};
searchIssue();
searchSuggestions();
return () => {
didCancel = true;
};
},
[debouncedSearch] // Only call effect if debounced search term changes
);

const suggestionSelected = (selection: Issue) => {
setIssue(selection);
// Update input box with selected issue
let element = ReactDOM.findDOMNode(document.getElementById("input-issue"));
element.value = selection.id.toString();
setIsAutoCompleteVisible(false);
};

const searchIssues = async (searchQuery: string) => {
let logout = false;

let payload = {
scope: "all",
all_words: "1",
titles_only: "0",
issues: "1",
news: "0",
// documents: "0" produces weird results.
changesets: "0",
wiki_pages: "0",
messages: "0",
// projects: "0" produces weird results.
open_issues: "1",
};

const foundIssues: { issues: Issue[] } = await fetch(
`${PUBLIC_API_URL}/api/search?q=${searchQuery}`,
{
method: "POST",
headers: headers,
body: JSON.stringify(payload),
}
)
.then((res) => {
if (res.ok) {
return res.json();
} else if (res.status === 401) {
logout = true;
} else {
throw new Error("Could not search for issues.");
}
})
.catch((error) => {
alert(error);
});
if (logout) context.setUser(null);
return foundIssues;
};

const handleAdd = (e) => {
if (issue === null) {
alert(
Expand All @@ -78,7 +147,7 @@ export const QuickAdd = ({
const pair: IssueActivityPair = {
issue: issue,
activity: activity,
custom_name: issue.subject + "-" + activity.name,
custom_name: issue.subject + " - " + activity.name,
is_hidden: false,
};

Expand All @@ -96,22 +165,30 @@ export const QuickAdd = ({

const getSearchClasses = () => {
let classes = "col-3 footer-field ";
if (search != "") classes += issue ? "valid" : "invalid";
if (search.text != "") classes += issue ? "valid" : "invalid";
return classes;
};

const getValidationIconSrc = () => {
let src = "";
if (search != "") src = issue ? check : x;
if (search.text != "") src = issue ? check : x;
return src;
};

const onEscapeInput = (e: any) => {
{
if (e.key === "Escape") {
setIsAutoCompleteVisible(false);
}
}
};

return (
<div>
<h2> Add a new row</h2>
<div className="row">
<label htmlFor="input-issue" className="col-3 input-label hidden">
Issue e.g. 3499
Issue (e.g. 3499) / free text
</label>
<label htmlFor="select-activity" className="col-3 select-label hidden">
Select activity
Expand All @@ -120,18 +197,20 @@ export const QuickAdd = ({
<div className="row">
<input
id="input-issue"
autoComplete="off"
className={getSearchClasses()}
type="number"
type="text"
min={0}
onKeyUp={(e) => onEscapeInput(e)}
onChange={(e) => {
setSearch(e.target.value);
setSearch({ ...search, text: e.target.value });
setIssue(null);
}}
title={(issue && issue.subject) || ""}
/>
<img
className={
search === "" ? "validation-icon hiden" : "validation-icon"
search.text === "" ? "validation-icon hiden" : "validation-icon"
}
src={getValidationIconSrc()}
alt="Validity"
Expand All @@ -157,6 +236,21 @@ export const QuickAdd = ({
<img src={plus} alt="Add line" />
</button>
</div>
{search.suggestions.length > 0 && isAutoCompleteVisible && (
<ul className="col-8 autocomplete-container">
{search.suggestions.map((item) => (
<li key={item.id} className="autocomplete-item">
<button
key={item.id}
onClick={() => suggestionSelected(item)}
className="autocomplete-button"
>
#{item.id} - {item.subject}
</button>
</li>
))}
</ul>
)}
</div>
);
};
Loading