[WP#5464]Apply `typeahead` with `debounce` while searching projects to create workpackage from Nextcloud (#664)

* apply typeahead with debounce while searching projects

Signed-off-by: Sagar <sagargurung1001@gmail.com>

* Review address

Signed-off-by: Sagar <sagargurung1001@gmail.com>

---------

Signed-off-by: Sagar <sagargurung1001@gmail.com>
This commit is contained in:
Sagar Gurung 2024-08-08 12:39:56 +05:45 коммит произвёл GitHub
Родитель a52320012b
Коммит 376e7181be
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
7 изменённых файлов: 322 добавлений и 56 удалений

Просмотреть файл

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Bump packages version
- Fix random deactivation of automatically managed project folder
- Fix avatar not found in openproject
- Enhance project search when creating workpackages from Nextcloud
## 2.6.3 - 2024-04-17
### Changed

Просмотреть файл

@ -361,15 +361,17 @@ class OpenProjectAPIController extends Controller {
/**
* @NoAdminRequired
* @param string|null $searchQuery
* @return DataResponse
*/
public function getAvailableOpenProjectProjects(): DataResponse {
public function getAvailableOpenProjectProjects(?string $searchQuery = null): DataResponse {
if ($this->accessToken === '') {
return new DataResponse('', Http::STATUS_UNAUTHORIZED);
} elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) {
return new DataResponse('', Http::STATUS_BAD_REQUEST);
}
try {
$result = $this->openprojectAPIService->getAvailableOpenProjectProjects($this->userId);
$result = $this->openprojectAPIService->getAvailableOpenProjectProjects($this->userId, $searchQuery);
} catch (OpenprojectErrorException $e) {
return new DataResponse($e->getMessage(), $e->getCode());
} catch (\Exception $e) {

Просмотреть файл

@ -1411,14 +1411,19 @@ class OpenProjectAPIService {
/**
* @param string $userId
* @param string|null $searchQuery
*
* @return array<mixed>
*
* @throws OpenprojectErrorException
* @throws OpenprojectResponseException|PreConditionNotMetException|\JsonException
*/
public function getAvailableOpenProjectProjects(string $userId): array {
public function getAvailableOpenProjectProjects(string $userId, string $searchQuery = null): array {
$resultsById = [];
$filters = [];
if ($searchQuery) {
$filters[] = ['typeahead' => ['operator' => '**', 'values' => [$searchQuery]]];
}
$filters[] = [
'storageUrl' =>
['operator' => '=', 'values' => [$this->urlGenerator->getBaseUrl()]],
@ -1426,7 +1431,8 @@ class OpenProjectAPIService {
['operator' => '&=', 'values' => ["file_links/manage", "work_packages/create"]]
];
$params = [
'filters' => json_encode($filters, JSON_THROW_ON_ERROR)
'filters' => json_encode($filters, JSON_THROW_ON_ERROR),
'pageSize' => 100
];
$result = $this->request($userId, 'work_packages/available_projects', $params);
if (isset($result['error'])) {

Просмотреть файл

@ -24,13 +24,15 @@
:append-to-body="false"
:value="getSelectedProject"
:no-drop="noDropAvailableProjectDropDown"
:loading="isStateLoading"
@search="asyncFindProjects"
@option:selected="onSelectProject">
<template #option="{ label, relation, counter }">
<span v-if="relation === 'child'" :style="{paddingLeft: counter + 'em' }" />
<span>{{ label }}</span>
</template>
<template #no-options>
{{ t('integration_openproject', 'Please link a project to this Nextcloud storage') }}
{{ getNoOptionText }}
</template>
</NcSelect>
<p v-if="error.error && error.attribute === 'project'" class="validation-error">
@ -158,6 +160,11 @@ import axios from '@nextcloud/axios'
import dompurify from 'dompurify'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { STATE } from '../utils.js'
import debounce from 'lodash/debounce.js'
const SEARCH_CHAR_LIMIT = 1
const DEBOUNCE_THRESHOLD = 500
const DEFAULT_TYPE_VALUE = {
self: {
@ -245,6 +252,9 @@ export default {
previousProjectId: null,
previousDescriptionTemplate: '',
isDescriptionTemplateChanged: false,
isFetchingProjectsFromOpenProjectWithQuery: false,
initialAvailableProjects: [],
state: STATE.OK,
}),
computed: {
openModal() {
@ -263,6 +273,9 @@ export default {
getSelectedProjectAssignee() {
return this.assignee.label
},
isStateLoading() {
return this.state === STATE.LOADING
},
getBodyForRequest() {
return {
body: {
@ -279,6 +292,13 @@ export default {
mappedNodes() {
return this.mappedProjects()
},
getNoOptionText() {
if (this.availableProjects.length === 0) {
return t('integration_openproject', 'No matching work projects found!')
}
// while projects are being searched we make the no text option empty
return ''
},
sanitizedRequiredCustomTypeValidationErrorMessage() {
// get the last number from the href i.e `/api/v3/types/1`, which is the type id
const typeID = parseInt(this.type.self.href.match(/\d+$/)[0], 10)
@ -315,12 +335,29 @@ export default {
// when the modal opens the dropdown for selecting project gains focus automatically
// this is a workaround to prevent that by bluring the focus and the enabling the dropDown that was
// disabled initially in data
if (this.$refs?.createWorkPackageProjectInput) {
if (this.$refs?.createWorkPackageProjectInput && this.isFetchingProjectsFromOpenProjectWithQuery === false) {
document.getElementById(`${this.$refs?.createWorkPackageProjectInput?.inputId}`).blur()
this.noDropAvailableProjectDropDown = false
}
return mappedNodes
},
async asyncFindProjects(query) {
// before fetching we do some filter search in the default available projects
let searchedAvailableProjects = []
searchedAvailableProjects = this.availableProjects.filter(element => element.label.toLowerCase().includes(query.toLowerCase()))
if (searchedAvailableProjects.length === 0) {
this.isFetchingProjectsFromOpenProjectWithQuery = true
await this.debounceMakeSearchRequest(query)
}
// After we have searched and we clear the searched query (empty), we list the initial default fetched available projects
if (query.trim() === '' && this.isFetchingProjectsFromOpenProjectWithQuery === true) {
this.availableProjects = this.initialAvailableProjects
}
},
debounceMakeSearchRequest: debounce(function(...args) {
if (args[0].length < SEARCH_CHAR_LIMIT) return
return this.searchForProjects(...args)
}, DEBOUNCE_THRESHOLD),
setToDefaultProjectType() {
this.type = structuredClone(DEFAULT_TYPE_VALUE)
},
@ -361,15 +398,30 @@ export default {
this.previousProjectId = null
this.isDescriptionTemplateChanged = false
this.previousDescriptionTemplate = ''
this.isFetchingProjectsFromOpenProjectWithQuery = false
this.initialAvailableProjects = []
},
async searchForProjects() {
async searchForProjects(searchQuery = null) {
const req = {}
if (searchQuery) {
this.state = STATE.LOADING
req.params = {
searchQuery,
}
}
const url = generateUrl('/apps/integration_openproject/projects')
try {
const response = await axios.get(url)
const response = await axios.get(url, req)
await this.processProjects(response.data)
} catch (e) {
console.error('Couldn\'t fetch openproject projects')
}
if (this.isFetchingProjectsFromOpenProjectWithQuery === false) {
this.initialAvailableProjects = this.availableProjects
}
if (this.isStateLoading) {
this.state = STATE.OK
}
},
async processProjects(projects) {
this.availableProjects = []

Просмотреть файл

@ -0,0 +1,76 @@
{
"12": {
"_type": "Project",
"id": 12,
"identifier": "searchedProject",
"name": "searchedProject",
"active": true,
"public": false,
"description": {
"format": "markdown",
"raw": "",
"html": ""
},
"createdAt": "2023-10-10T11:20:55Z",
"updatedAt": "2023-10-10T11:20:55Z",
"statusExplanation": {
"format": "markdown",
"raw": "",
"html": ""
},
"_links": {
"self": {
"href": "/api/v3/projects/12",
"title": "searchedProject"
},
"createWorkPackage": {
"href": "/api/v3/projects/12/work_packages/form",
"method": "post"
},
"createWorkPackageImmediately": {
"href": "/api/v3/projects/12/work_packages",
"method": "post"
},
"workPackages": {
"href": "/api/v3/projects/12/work_packages"
},
"categories": {
"href": "/api/v3/projects/12/categories"
},
"versions": {
"href": "/api/v3/projects/12/versions"
},
"memberships": {
"href": "/api/v3/memberships?filters=%5B%7B%22project%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%227%22%5D%7D%7D%5D"
},
"types": {
"href": "/api/v3/projects/12/types"
},
"update": {
"href": "/api/v3/projects/12/form",
"method": "post"
},
"updateImmediately": {
"href": "/api/v3/projects/12",
"method": "patch"
},
"delete": {
"href": "/api/v3/projects/12",
"method": "delete"
},
"schema": {
"href": "/api/v3/projects/schema"
},
"status": {
"href": null
},
"projectStorages": {
"href": "/api/v3/project_storages?filters=%5B%7B%22projectId%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%227%22%5D%7D%7D%5D"
},
"parent": {
"href": "/api/v3/projects/5",
"title": "[dev] Large child"
}
}
}
}

Просмотреть файл

@ -4,6 +4,7 @@ import { createLocalVue, mount } from '@vue/test-utils'
import CreateWorkPackageModal from '../../../src/views/CreateWorkPackageModal.vue'
import axios from '@nextcloud/axios'
import availableProjectsResponse from '../fixtures/openprojectAvailableProjectResponse.json'
import availableProjectsResponseAfterSearch from '../fixtures/openprojectAvailableProjectResponseAfterSearch.json'
import availableProjectsOption from '../fixtures/availableProjectOptions.json'
import workpackageFormValidationProjectSelected from '../fixtures/workpackageFormValidationProjectSelectedResponse.json'
import workpackageFormValidationTypeChanged from '../fixtures/workpackageFormValidationTypeChanged.json'
@ -30,6 +31,7 @@ jest.mock('@nextcloud/initial-state', () => {
describe('CreateWorkPackageModal.vue', () => {
const createWorkPackageSelector = '.create-workpackage-modal'
const projectSelectSelector = '[data-test-id="available-projects"]'
const firstProjectSelectorSelector = '[data-test-id="available-projects"] [role="listbox"] > li'
const statusSelectSelector = '[data-test-id="available-statuses"]'
const typeSelectSelector = '[data-test-id="available-types"]'
const assigneesSelectSelector = '[data-test-id="available-assignees"]'
@ -65,12 +67,90 @@ describe('CreateWorkPackageModal.vue', () => {
}))
wrapper = mountWrapper(true)
expect(wrapper.find(createWorkPackageSelector).isVisible()).toBe(true)
expect(axiosSpy).toHaveBeenCalledWith('http://localhost/apps/integration_openproject/projects')
expect(axiosSpy).toHaveBeenCalledWith('http://localhost/apps/integration_openproject/projects', {})
await wrapper.find(projectInputField).setValue(' ')
expect(wrapper.find(projectSelectSelector)).toMatchSnapshot()
axiosSpy.mockRestore()
jest.clearAllMocks()
})
describe('search projects with query', () => {
let axiosSpy, inputField
beforeEach(async () => {
axiosSpy = jest.spyOn(axios, 'get')
.mockImplementationOnce(() => Promise.resolve({
status: 200,
data: availableProjectsResponse,
}))
wrapper = mountWrapper(true)
expect(wrapper.find(createWorkPackageSelector).isVisible()).toBe(true)
expect(axiosSpy).toHaveBeenCalledWith('http://localhost/apps/integration_openproject/projects', {})
inputField = wrapper.find(projectInputField)
await inputField.setValue('Sc')
})
it('should send a search query request when searched project is not found', async () => {
await inputField.setValue('Scw')
// for a search debounce request, minimum 500 ms wait is required
await new Promise(resolve => setTimeout(resolve, 500))
expect(axiosSpy).toHaveBeenCalledWith('http://localhost/apps/integration_openproject/projects',
{
params: {
searchQuery: 'Scw',
},
},
)
})
it('should show "No matching work projects found!" when the searched project is not found', async () => {
const axiosSpyWithSearchQuery = jest.spyOn(axios, 'get')
.mockImplementationOnce(() => Promise.resolve({
status: 200,
data: [],
}))
await inputField.setValue('Scw')
expect(wrapper.vm.isFetchingProjectsFromOpenProjectWithQuery).toBe(true)
// for a search debounce request, minimum 500 ms wait is required
await new Promise(resolve => setTimeout(resolve, 500))
expect(axiosSpyWithSearchQuery).toHaveBeenCalledWith('http://localhost/apps/integration_openproject/projects',
{
params: {
searchQuery: 'Scw',
},
},
)
const searchResult = wrapper.find(firstProjectSelectorSelector)
expect(searchResult.text()).toBe('No matching work projects found!')
})
it('should fetch projects when not found in initial available projects', async () => {
const axiosSpyWithSearchQuery = jest.spyOn(axios, 'get')
.mockImplementationOnce(() => Promise.resolve({
status: 200,
data: availableProjectsResponseAfterSearch,
}))
const inputField = wrapper.find(projectInputField)
await inputField.setValue('se')
// for a search debounce request, minimum 500 ms wait is required
await new Promise(resolve => setTimeout(resolve, 500))
expect(axiosSpyWithSearchQuery).toHaveBeenCalledWith('http://localhost/apps/integration_openproject/projects',
{
params: {
searchQuery: 'se',
},
},
)
const searchResult = wrapper.find(firstProjectSelectorSelector)
expect(searchResult.text()).toBe('searchedProject')
})
it('should set available projects to initially fetched projects when nothing searched', async () => {
await inputField.setValue(' ')
const searchResult = wrapper.findAll(projectOptionsSelector)
// the initially fetched available projects include 7 openproject projects
expect(searchResult.length).toBe(7)
})
})
it('should set the available types, status and assignee when a project is selected', async () => {
const formValidationBody = {
body: {

Просмотреть файл

@ -324,6 +324,55 @@ class OpenProjectAPIServiceTest extends TestCase {
]
];
/**
* @var array<mixed>
*/
private $expectedValidOpenProjectResponse = [
6 => [
"_type" => "Project",
"id" => 6,
"identifier" => "dev-custom-fields",
"name" => "[dev] Custom fields",
"_links" => [
"self" => [
"href" => "/api/v3/projects/6",
"title" => "[dev] Custom fields"
],
"parent" => [
"href" => "/api/v3/projects/5",
"title" => "[dev] Large"
],
"storages" => [
[
"href" => "/api/v3/storages/37",
"title" => "nc-26"
]
],
]
],
5 => [
"_type" => "Project",
"id" => 5,
"identifier" => "dev-large",
"name" => "[dev] Large",
"_links" => [
"self" => [
"href" => "/api/v3/projects/5",
"title" => "[dev] Large"
],
"parent" => [
"href" => null
],
"storages" => [
[
"href" => "/api/v3/storages/37",
"title" => "nc-26"
]
]
]
]
];
/**
* @var array<mixed>
*/
@ -3288,51 +3337,6 @@ class OpenProjectAPIServiceTest extends TestCase {
* @return void
*/
public function testGetAvailableOpenProjectProjectsPact(): void {
$expectedResult = [
6 => [
"_type" => "Project",
"id" => 6,
"identifier" => "dev-custom-fields",
"name" => "[dev] Custom fields",
"_links" => [
"self" => [
"href" => "/api/v3/projects/6",
"title" => "[dev] Custom fields"
],
"parent" => [
"href" => "/api/v3/projects/5",
"title" => "[dev] Large"
],
"storages" => [
[
"href" => "/api/v3/storages/37",
"title" => "nc-26"
]
],
]
],
5 => [
"_type" => "Project",
"id" => 5,
"identifier" => "dev-large",
"name" => "[dev] Large",
"_links" => [
"self" => [
"href" => "/api/v3/projects/5",
"title" => "[dev] Large"
],
"parent" => [
"href" => null
],
"storages" => [
[
"href" => "/api/v3/storages/37",
"title" => "nc-26"
]
]
]
]
];
$filters[] = [
'storageUrl' =>
['operator' => '=', 'values' => ['https://nc.my-server.org']],
@ -3345,7 +3349,10 @@ class OpenProjectAPIServiceTest extends TestCase {
->setPath($this->getProjectsPath)
->setHeaders(["Authorization" => "Bearer 1234567890"])
->setQuery(
['filters' => json_encode($filters, JSON_THROW_ON_ERROR)]
[
'filters' => json_encode($filters, JSON_THROW_ON_ERROR),
'pageSize' => (string) 100
]
);
$providerResponse = new ProviderResponse();
$providerResponse
@ -3359,7 +3366,7 @@ class OpenProjectAPIServiceTest extends TestCase {
$storageMock = $this->getStorageMock();
$service = $this->getOpenProjectAPIService($storageMock);
$result = $service->getAvailableOpenProjectProjects('testUser');
$this->assertSame(sort($expectedResult), sort($result));
$this->assertSame(sort($this->expectedValidOpenProjectResponse), sort($result));
}
/**
@ -3386,6 +3393,48 @@ class OpenProjectAPIServiceTest extends TestCase {
$service->getAvailableOpenProjectProjects('testUser');
}
/**
* @return void
*/
public function testGetAvailableOpenProjectProjectsQueryOnly() {
$iUrlGeneratorMock = $this->getMockBuilder(IURLGenerator::class)->disableOriginalConstructor()->getMock();
$iUrlGeneratorMock->method('getBaseUrl')->willReturn('https%3A%2F%2Fnc.my-server.org');
$service = $this->getServiceMock(
['request'],
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
$iUrlGeneratorMock
);
$service->method('request')
->with(
'user', 'work_packages/available_projects',
[
'filters' => '[' .
'{"typeahead":' .
'{"operator":"**","values":["search query"]}'.
'},'.
'{"storageUrl":'.
'{"operator":"=","values":["https%3A%2F%2Fnc.my-server.org"]},'.
'"userAction":'.
'{"operator":"&=","values":["file_links\/manage","work_packages\/create"]}}'.
']',
'pageSize' => 100
],
)
->willReturn($this->validOpenProjectGetProjectsResponse);
$result = $service->getAvailableOpenProjectProjects('user', 'search query');
$this->assertSame($this->expectedValidOpenProjectResponse, $result);
}
/**
* @group ignoreWithPHP8.0
* @return void