Skip to content

Commit

Permalink
Feat: Add SearchBar to tile, Create Search Page, Add API for search
Browse files Browse the repository at this point in the history
Currently only included projects, tags and versions.

Feat(web): Add Search for Versions to Search Page

Now the search also returns project versions that use the correct link
(to the version itself). The project still links to the newest version.

fixes: #13

Feat: Add Search functionality for project, tags and versions
  • Loading branch information
reglim committed Oct 17, 2022
1 parent 402862a commit 9b1e8fc
Show file tree
Hide file tree
Showing 8 changed files with 532 additions and 66 deletions.
58 changes: 57 additions & 1 deletion docat/docat/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from typing import Optional, Tuple

import magic
from fastapi import Depends, FastAPI, File, Header, Response, UploadFile, status
Expand Down Expand Up @@ -71,6 +71,27 @@ class ProjectDetailResponse(BaseModel):
versions: list[ProjectVersion]


class SearchResultProject(BaseModel):
name: str


class SearchResultVersion(BaseModel):
project: str
version: str


class SearchResultFile(BaseModel):
project: str
version: str
path: str


class SearchResponse(BaseModel):
projects: list[SearchResultProject]
versions: list[SearchResultVersion]
files: list[SearchResultFile]


@app.get("/api/projects", response_model=ProjectsResponse, status_code=status.HTTP_200_OK)
def get_projects():
if not DOCAT_UPLOAD_FOLDER.exists():
Expand Down Expand Up @@ -114,6 +135,41 @@ def get_project(project):
)


@app.get("/api/search", response_model=SearchResponse, status_code=status.HTTP_200_OK)
@app.get("/api/search/", response_model=SearchResponse, status_code=status.HTTP_200_OK)
def search(query: str):
query = query.lower()
found_projects: list[SearchResultProject] = list()
found_versions: list[SearchResultVersion] = list()
found_files: list[SearchResultFile] = list()

all_projects = get_projects().projects
all_versions: list[Tuple[str, ProjectVersion]] = list()

# Collect all projects that contain the query
for project in all_projects:
project_details = get_project(project)

all_versions += ((project, v) for v in project_details.versions)

if query in project:
project_res = SearchResultProject(name=project)
found_projects.append(project_res)

# Collect all versions and tags that contain the query
for (project, version) in all_versions:
if query in version.name:
res = SearchResultVersion(version=version.name, project=project)
found_versions.append(res)

for tag in version.tags:
if query in tag:
res = SearchResultVersion(version=tag, project=project)
found_versions.append(res)

return SearchResponse(projects=found_projects, versions=found_versions, files=found_files)


@app.post("/api/{project}/icon", response_model=ApiResponse, status_code=status.HTTP_200_OK)
@app.post("/api/{project}/icon/", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def upload_icon(
Expand Down
176 changes: 176 additions & 0 deletions docat/tests/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import io


def test_search_finds_project_by_name(client_with_claimed_project):
"""
Search should find a project by name. (Partial match)
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=some")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [{"name": "some-project"}], "versions": [], "files": []}


def test_search_finds_project_by_name_full_match(client_with_claimed_project):
"""
Search should find a project by name. (Full match)
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=some-project")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [{"name": "some-project"}], "versions": [], "files": []}


def test_search_project_by_name_negative(client_with_claimed_project):
"""
Search should not find a project by an unrelated name.
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=other")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [], "versions": [], "files": []}


def test_search_finds_tag(client_with_claimed_project):
"""
Search should find a tag by name. (Partial match)
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest")
assert create_tag_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=lat")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [], "versions": [{"project": "some-project", "version": "latest"}], "files": []}


def test_search_finds_tag_full_match(client_with_claimed_project):
"""
Search should find a tag by name. (Full match)
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest")
assert create_tag_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=latest")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [], "versions": [{"project": "some-project", "version": "latest"}], "files": []}


def test_search_finds_tag_negative(client_with_claimed_project):
"""
Search should not find a tag by an unrelated name.
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest")
assert create_tag_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=other")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [], "versions": [], "files": []}


def test_search_finds_version(client_with_claimed_project):
"""
Search should find a version by name. (Partial match)
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=1.0")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [], "versions": [{"project": "some-project", "version": "1.0.0"}], "files": []}


def test_search_finds_version_full_match(client_with_claimed_project):
"""
Search should find a version by name. (Full match)
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=1.0.0")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [], "versions": [{"project": "some-project", "version": "1.0.0"}], "files": []}


def test_search_finds_version_negative(client_with_claimed_project):
"""
Search should not find a version by an unrelated name.
"""
create_project_response = client_with_claimed_project.post(
"/api/some-project/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=0.1.0")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [], "versions": [], "files": []}


def test_search_finds_both_project_and_version(client_with_claimed_project):
"""
Search should find both the version and the project itself, if the names contain the query.
"""
create_project_response = client_with_claimed_project.post(
"/api/test/test",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=test")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [{"name": "test"}], "versions": [{"project": "test", "version": "test"}], "files": []}


def test_search_is_case_insensitive(client_with_claimed_project):
"""
Search should find the project even when the case doesn't match.
"""

create_project_response = client_with_claimed_project.post(
"/api/test/1.0.0",
files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")},
)
assert create_project_response.status_code == 201

search_response = client_with_claimed_project.get("/api/search?query=Test")
assert search_response.status_code == 200
assert search_response.json() == {"projects": [{"name": "test"}], "versions": [], "files": []}
18 changes: 12 additions & 6 deletions web/src/components/Layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<div v-html="header" />
</router-link>
<slot name="toolbar"></slot>

<SearchBar />
</div>
<div class="md-layout-item md-size-15 md-small-hide"></div>
</div>
Expand All @@ -29,34 +31,38 @@

<script>
import ProjectRepository from '@/repositories/ProjectRepository'
import SearchBar from '@/components/SearchBar.vue'
export default {
name: 'layout',
props: {
fullscreen: Boolean,
},
data() {
const defaultHeader = '<img class="logo" alt="docat logo" src="' + require('../assets/logo.png') + '" /><h1>DOCAT</h1>'
return {
header: defaultHeader,
}
},
components: {
SearchBar,
},
async created() {
const config = await ProjectRepository.getConfig()
if (config.hasOwnProperty('headerHTML')){
if (config.hasOwnProperty('headerHTML')) {
this.header = config.headerHTML
}
}
},
}
</script>

<style lang="scss">
@import "~vue-material/dist/theme/engine"; // Import the theme engine
@include md-register-theme("default", (
primary: #383838, // The primary color of your application
accent: #868686 // The accent or secondary color
));
@include md-register-theme("default", (primary: #383838, // The primary color of your application
accent: #868686 // The accent or secondary color
));
@import "~vue-material/dist/theme/all"; // Apply the theme
Expand Down
31 changes: 31 additions & 0 deletions web/src/components/SearchBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<template>
<input type="search" class="searchbar-small" placeholder="Search Projects" v-model="searchQuery"
@change="initialSearch()" />
</template>

<script>
export default {
name: 'searchbar',
data() {
return {
searchQuery: ''
}
},
methods: {
initialSearch() {
this.$router.push({ path: '/search', query: { searchQuery: this.searchQuery } })
}
}
}
</script>

<style lang="scss">
.searchbar-small {
padding: 10px;
border: 1px solid #e8e8e8;
border-radius: 7px;
margin-top: 16px;
float: right;
}
</style>
2 changes: 2 additions & 0 deletions web/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ import Help from '@/pages/Help.vue'
import Upload from '@/pages/Upload.vue'
import Claim from '@/pages/Claim.vue'
import Delete from '@/pages/Delete.vue'
import Search from '@/pages/Search.vue'

const routes = [
{ path: '/', component: Home },
{ path: '/help', component: Help },
{ path: '/upload', component: Upload },
{ path: '/claim', component: Claim },
{ path: '/delete', component: Delete },
{ path: '/search', component: Search},
{ path: '/:project/:version?/:location(.*)?', component: Docs }
]

Expand Down
Loading

0 comments on commit 9b1e8fc

Please sign in to comment.