[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:
Родитель
a52320012b
Коммит
376e7181be
|
@ -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
|
||||
|
|
Загрузка…
Ссылка в новой задаче