From 3906597c9f1f54bcddfa5dedceb5e213020a032c Mon Sep 17 00:00:00 2001 From: Swikriti Tripathi Date: Tue, 1 Aug 2023 09:33:06 +0545 Subject: [PATCH 1/5] reuse component Signed-off-by: Swikriti Tripathi reuse Signed-off-by: Swikriti Tripathi emit event Signed-off-by: Swikriti Tripathi add op icon vue Signed-off-by: Swikriti Tripathi --- lib/Listener/OpenProjectReferenceListener.php | 27 ++++++ .../WorkPackageReferenceProvider.php | 9 +- lib/Search/OpenProjectSearchProvider.php | 20 +--- src/components/icons/OpenProjectIcon.vue | 31 ++++++ src/components/tab/EmptyContent.vue | 20 ++-- src/components/tab/SearchInput.vue | 58 ++++++++--- src/reference.js | 19 +++- src/views/WorkPackagePickerElement.vue | 97 +++++++++++++++++++ 8 files changed, 239 insertions(+), 42 deletions(-) create mode 100644 src/components/icons/OpenProjectIcon.vue create mode 100644 src/views/WorkPackagePickerElement.vue diff --git a/lib/Listener/OpenProjectReferenceListener.php b/lib/Listener/OpenProjectReferenceListener.php index b6d84eae6..e802197b4 100644 --- a/lib/Listener/OpenProjectReferenceListener.php +++ b/lib/Listener/OpenProjectReferenceListener.php @@ -23,15 +23,36 @@ namespace OCA\OpenProject\Listener; use OCA\OpenProject\AppInfo\Application; +use OCA\OpenProject\Service\OpenProjectAPIService; +use OCP\AppFramework\Services\IInitialState; use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +use OCP\IConfig; use OCP\Util; /** * @template-implements IEventListener */ class OpenProjectReferenceListener implements IEventListener { + + /** + * @var IInitialState + */ + private $initialStateService; + + /** + * @var IConfig + */ + private $config; + + public function __construct( + IInitialState $initialStateService, + IConfig $config + ) { + $this->initialStateService = $initialStateService; + $this->config = $config; + } public function handle(Event $event): void { // @phpstan-ignore-next-line - make phpstan not complain in nextcloud version other than 26 if (!$event instanceof RenderReferenceEvent) { @@ -39,5 +60,11 @@ public function handle(Event $event): void { } Util::addScript(Application::APP_ID, Application::APP_ID . '-reference'); + $this->initialStateService->provideInitialState('admin-config-status', OpenProjectAPIService::isAdminConfigOk($this->config)); + + $this->initialStateService->provideInitialState( + 'openproject-url', + $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url') + ); } } diff --git a/lib/Reference/WorkPackageReferenceProvider.php b/lib/Reference/WorkPackageReferenceProvider.php index b3e26e56d..37c87d320 100644 --- a/lib/Reference/WorkPackageReferenceProvider.php +++ b/lib/Reference/WorkPackageReferenceProvider.php @@ -34,7 +34,7 @@ use OCP\IL10N; use OCP\IURLGenerator; -class WorkPackageReferenceProvider extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider { +class WorkPackageReferenceProvider extends ADiscoverableReferenceProvider { private const RICH_OBJECT_TYPE = Application::APP_ID . '_work_package'; // as we know we are on NC >= 26, we can use Php 8 syntax for class attributes @@ -76,13 +76,6 @@ public function getIconUrl(): string { ); } - /** - * @inheritDoc - */ - public function getSupportedSearchProviderIds(): array { - return ['openproject-search']; - } - /** * Parse a link to find a work package ID * diff --git a/lib/Search/OpenProjectSearchProvider.php b/lib/Search/OpenProjectSearchProvider.php index e6a85957b..c8e16175f 100644 --- a/lib/Search/OpenProjectSearchProvider.php +++ b/lib/Search/OpenProjectSearchProvider.php @@ -110,24 +110,14 @@ public function search(IUser $user, ISearchQuery $query): SearchResult { $offset = $offset ? intval($offset) : 0; $openprojectUrl = OpenProjectAPIService::sanitizeUrl($this->config->getAppValue(Application::APP_ID, 'openproject_instance_url')); $accessToken = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'token'); - - if ($accessToken === '') { + $searchEnabled = $this->config->getUserValue( + $user->getUID(), + Application::APP_ID, 'search_enabled', + $this->config->getAppValue(Application::APP_ID, 'default_enable_unified_search', '0')) === '1'; + if ($accessToken === '' || !$searchEnabled) { return SearchResult::paginated($this->getName(), [], 0); } - $routeFrom = $query->getRoute(); - $requestedFromSmartPicker = $routeFrom === '' || $routeFrom === 'smart-picker'; - - if (!$requestedFromSmartPicker) { - $searchEnabled = $this->config->getUserValue( - $user->getUID(), - Application::APP_ID, 'search_enabled', - $this->config->getAppValue(Application::APP_ID, 'default_enable_unified_search', '0')) === '1'; - if (!$searchEnabled) { - return SearchResult::paginated($this->getName(), [], 0); - } - } - $searchResults = $this->service->searchWorkPackage($user->getUID(), $term, null, false); $searchResults = array_slice($searchResults, $offset, $limit); diff --git a/src/components/icons/OpenProjectIcon.vue b/src/components/icons/OpenProjectIcon.vue new file mode 100644 index 000000000..2be36a9a3 --- /dev/null +++ b/src/components/icons/OpenProjectIcon.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/tab/EmptyContent.vue b/src/components/tab/EmptyContent.vue index 0ad99eb51..621ab5361 100644 --- a/src/components/tab/EmptyContent.vue +++ b/src/components/tab/EmptyContent.vue @@ -3,10 +3,11 @@
- + +
-
+
{{ emptyContentTitleMessage }}
@@ -25,6 +26,7 @@ import LinkPlusIcon from 'vue-material-design-icons/LinkPlus.vue' import LinkOffIcon from 'vue-material-design-icons/LinkOff.vue' import CheckIcon from 'vue-material-design-icons/Check.vue' +import OpenProjectIcon from '../icons/OpenProjectIcon.vue' import { generateUrl } from '@nextcloud/router' import { translate as t } from '@nextcloud/l10n' import OAuthConnectButton from '../OAuthConnectButton.vue' @@ -32,7 +34,7 @@ import { STATE } from '../../utils.js' export default { name: 'EmptyContent', - components: { OAuthConnectButton, LinkPlusIcon, LinkOffIcon, CheckIcon }, + components: { OAuthConnectButton, LinkPlusIcon, LinkOffIcon, CheckIcon, OpenProjectIcon }, props: { state: { type: String, @@ -57,6 +59,10 @@ export default { type: Boolean, default: false, }, + isSmartPicker: { + type: Boolean, + default: false, + }, }, data() { return { @@ -109,9 +115,11 @@ export default { display: flex; align-items: center; justify-content: center; - img { - height: 50px; - width: 50px; + &--openproject { + margin: 90px; + display: block; + align-items: center; + justify-content: center; } } &--message { diff --git a/src/components/tab/SearchInput.vue b/src/components/tab/SearchInput.vue index c486dc2fd..5cd68a350 100644 --- a/src/components/tab/SearchInput.vue +++ b/src/components/tab/SearchInput.vue @@ -28,6 +28,7 @@ + + From 9f7a31d7d92c4e085760dfeb3d0b8075897f47b7 Mon Sep 17 00:00:00 2001 From: Swikriti Tripathi Date: Tue, 1 Aug 2023 16:42:42 +0545 Subject: [PATCH 2/5] fix failing tests Signed-off-by: Swikriti Tripathi --- tests/jest/components/tab/SearchInput.spec.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/jest/components/tab/SearchInput.spec.js b/tests/jest/components/tab/SearchInput.spec.js index 13afea1fa..ac51ffed8 100644 --- a/tests/jest/components/tab/SearchInput.spec.js +++ b/tests/jest/components/tab/SearchInput.spec.js @@ -9,6 +9,7 @@ import workPackagesSearchResponseNoAssignee from '../../fixtures/workPackagesSea import workPackageSearchReqResponse from '../../fixtures/workPackageSearchReqResponse.json' import workPackageObjectsInSearchResults from '../../fixtures/workPackageObjectsInSearchResults.json' import { STATE } from '../../../../src/utils.js' +import * as initialState from "@nextcloud/initial-state"; jest.mock('@nextcloud/axios') jest.mock('@nextcloud/dialogs') @@ -23,6 +24,13 @@ jest.mock('lodash/debounce', () => }) ) +initialState.loadState = jest.fn(() => { + return { + openproject_instance_url: null, + } +}) + + global.t = (app, text) => text const localVue = createLocalVue() From 8558e070b5de9adcdc84565ddf51a92f66269d99 Mon Sep 17 00:00:00 2001 From: Swikriti Tripathi Date: Tue, 1 Aug 2023 17:10:13 +0545 Subject: [PATCH 3/5] check for access token Signed-off-by: Swikriti Tripathi --- lib/Reference/WorkPackageReferenceProvider.php | 2 +- lib/Service/OpenProjectAPIService.php | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/Reference/WorkPackageReferenceProvider.php b/lib/Reference/WorkPackageReferenceProvider.php index 37c87d320..5c8b3959d 100644 --- a/lib/Reference/WorkPackageReferenceProvider.php +++ b/lib/Reference/WorkPackageReferenceProvider.php @@ -117,7 +117,7 @@ public function matchReference(string $referenceText): bool { * @inheritDoc */ public function resolveReference(string $referenceText): ?IReference { - if ($this->matchReference($referenceText)) { + if ($this->matchReference($referenceText) && OpenProjectAPIService::isAdminConfigOk($this->config)) { $wpId = $this->getWorkPackageIdFromUrl($referenceText); if ($wpId !== null) { $wpInfo = $this->openProjectAPIService->getWorkPackageInfo($this->userId, $wpId); diff --git a/lib/Service/OpenProjectAPIService.php b/lib/Service/OpenProjectAPIService.php index 47a748fb2..f6edfd56b 100644 --- a/lib/Service/OpenProjectAPIService.php +++ b/lib/Service/OpenProjectAPIService.php @@ -1151,11 +1151,14 @@ public function hasAppPassword(): bool { */ public function getWorkPackageInfo(string $userId, int $wpId): array { $result[] = null; - $searchResult = $this->searchWorkPackage($userId, null, null, false, $wpId); - $result['title'] = $this->getSubline($searchResult[0]); - $result['description'] = $this->getMainText($searchResult[0]); - $result['imageUrl'] = $this->getOpenProjectUserAvatarUrl($searchResult[0]); - $result['entry'] = $searchResult[0]; + $accessToken = $this->config->getUserValue($userId, Application::APP_ID, 'token'); + if ($accessToken) { + $searchResult = $this->searchWorkPackage($userId, null, null, false, $wpId); + $result['title'] = $this->getSubline($searchResult[0]); + $result['description'] = $this->getMainText($searchResult[0]); + $result['imageUrl'] = $this->getOpenProjectUserAvatarUrl($searchResult[0]); + $result['entry'] = $searchResult[0]; + } return $result; } From e8c209d66e5d172f55983054a8d99f9da190720d Mon Sep 17 00:00:00 2001 From: Swikriti Tripathi Date: Wed, 2 Aug 2023 11:48:43 +0545 Subject: [PATCH 4/5] add unit test Signed-off-by: Swikriti Tripathi --- lib/Controller/OpenProjectAPIController.php | 10 ++-- src/components/tab/SearchInput.vue | 13 ++--- tests/jest/components/tab/SearchInput.spec.js | 53 ++++++++++++++++++- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/lib/Controller/OpenProjectAPIController.php b/lib/Controller/OpenProjectAPIController.php index b7b314cd7..24d92f504 100644 --- a/lib/Controller/OpenProjectAPIController.php +++ b/lib/Controller/OpenProjectAPIController.php @@ -154,17 +154,21 @@ public function getNotifications(): DataResponse { * * @return DataResponse */ - public function getSearchedWorkPackages(?string $searchQuery = null, ?int $fileId = null): DataResponse { + public function getSearchedWorkPackages(?string $searchQuery = null, ?int $fileId = null, bool $isSmartPicker = false): DataResponse { if ($this->accessToken === '') { return new DataResponse('', Http::STATUS_UNAUTHORIZED); } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { return new DataResponse('', Http::STATUS_BAD_REQUEST); } - + $onlyLinkableWorkPackages = true; + if ($isSmartPicker) { + $onlyLinkableWorkPackages = false; + } $result = $this->openprojectAPIService->searchWorkPackage( $this->userId, $searchQuery, - $fileId + $fileId, + $onlyLinkableWorkPackages ); if (!isset($result['error'])) { diff --git a/src/components/tab/SearchInput.vue b/src/components/tab/SearchInput.vue index 5cd68a350..969216910 100644 --- a/src/components/tab/SearchInput.vue +++ b/src/components/tab/SearchInput.vue @@ -132,13 +132,9 @@ export default { }, async asyncFind(query) { this.resetState() - if (this.isSmartPicker) { - await this.debounceMakeSearchRequest(query) - } else { - await this.debounceMakeSearchRequest(query, this.fileInfo.id) - } + await this.debounceMakeSearchRequest(query, this.fileInfo.id, this.isSmartPicker) }, - async getFileLink(selectedOption) { + async getWorkPackageLink(selectedOption) { return this.openprojectUrl + '/projects/' + selectedOption.projectId + '/work_packages/' + selectedOption.id }, debounceMakeSearchRequest: debounce(function(...args) { @@ -147,7 +143,7 @@ export default { }, DEBOUNCE_THRESHOLD), async linkWorkPackageToFile(selectedOption) { if (this.isSmartPicker) { - const link = await this.getFileLink(selectedOption) + const link = await this.getWorkPackageLink(selectedOption) this.$emit('submit', link) return } @@ -176,12 +172,13 @@ export default { ) } }, - async makeSearchRequest(search, fileId = null) { + async makeSearchRequest(search, fileId = null, isSmartPicker = false) { this.state = STATE.LOADING const url = generateUrl('/apps/integration_openproject/work-packages') const req = {} req.params = { searchQuery: search, + isSmartPicker, } let response try { diff --git a/tests/jest/components/tab/SearchInput.spec.js b/tests/jest/components/tab/SearchInput.spec.js index ac51ffed8..fdf585103 100644 --- a/tests/jest/components/tab/SearchInput.spec.js +++ b/tests/jest/components/tab/SearchInput.spec.js @@ -9,7 +9,7 @@ import workPackagesSearchResponseNoAssignee from '../../fixtures/workPackagesSea import workPackageSearchReqResponse from '../../fixtures/workPackageSearchReqResponse.json' import workPackageObjectsInSearchResults from '../../fixtures/workPackageObjectsInSearchResults.json' import { STATE } from '../../../../src/utils.js' -import * as initialState from "@nextcloud/initial-state"; +import * as initialState from '@nextcloud/initial-state' jest.mock('@nextcloud/axios') jest.mock('@nextcloud/dialogs') @@ -24,13 +24,13 @@ jest.mock('lodash/debounce', () => }) ) +// eslint-disable-next-line no-import-assign,import/namespace initialState.loadState = jest.fn(() => { return { openproject_instance_url: null, } }) - global.t = (app, text) => text const localVue = createLocalVue() @@ -151,6 +151,7 @@ describe('SearchInput.vue', () => { { params: { searchQuery: 'orga', + isSmartPicker: false, }, }, ) @@ -495,7 +496,55 @@ describe('SearchInput.vue', () => { }) }) }) + + describe('search with smartpicker', () => { + let axiosGetSpy + beforeEach(async () => { + axiosGetSpy = jest.spyOn(axios, 'get') + .mockImplementationOnce(() => Promise.resolve({ + status: 200, + data: [], + })) + wrapper = mountSearchInput() + const inputField = wrapper.find(inputSelector) + await inputField.setValue('orga') + await wrapper.setData({ + searchResults: [{ + id: 999, + projectId: 1, + }], + openprojectUrl: 'https://openproject.com', + }) + await localVue.nextTick() + await wrapper.setProps({ + isSmartPicker: true, + }) + await localVue.nextTick() + }) + afterEach(() => { + axiosGetSpy.mockRestore() + }) + it('should emit an action', async () => { + const ncSelectItem = wrapper.find(firstWorkPackageSelector) + await ncSelectItem.trigger('click') + const savedEvent = wrapper.emitted('submit') + expect(savedEvent).toHaveLength(1) + expect(savedEvent[0][0]).toEqual('https://openproject.com/projects/1/work_packages/999') + }) + + it('should not send a request to link file to workpackage', async () => { + const postSpy = jest.spyOn(axios, 'post') + .mockImplementationOnce(() => Promise.resolve({ + status: 200, + })) + const ncSelectItem = wrapper.find(firstWorkPackageSelector) + await ncSelectItem.trigger('click') + expect(postSpy).not.toBeCalled() + postSpy.mockRestore() + }) + }) }) + function mountSearchInput(fileInfo = {}, linkedWorkPackages = [], data = {}) { return mount(SearchInput, { localVue, From 0ef69eb8aed5ae7f830768fe0240b14358272465 Mon Sep 17 00:00:00 2001 From: Swikriti Tripathi Date: Thu, 3 Aug 2023 12:26:09 +0545 Subject: [PATCH 5/5] address review Signed-off-by: Swikriti Tripathi --- lib/Controller/OpenProjectAPIController.php | 7 ++----- src/components/tab/SearchInput.vue | 5 ++--- src/views/WorkPackagePickerElement.vue | 6 ------ 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/lib/Controller/OpenProjectAPIController.php b/lib/Controller/OpenProjectAPIController.php index 24d92f504..037899865 100644 --- a/lib/Controller/OpenProjectAPIController.php +++ b/lib/Controller/OpenProjectAPIController.php @@ -160,15 +160,12 @@ public function getSearchedWorkPackages(?string $searchQuery = null, ?int $fileI } elseif (!OpenProjectAPIService::validateURL($this->openprojectUrl)) { return new DataResponse('', Http::STATUS_BAD_REQUEST); } - $onlyLinkableWorkPackages = true; - if ($isSmartPicker) { - $onlyLinkableWorkPackages = false; - } + // when the search is done through smart picker we don't want to check if the work package is linkable $result = $this->openprojectAPIService->searchWorkPackage( $this->userId, $searchQuery, $fileId, - $onlyLinkableWorkPackages + !$isSmartPicker ); if (!isset($result['error'])) { diff --git a/src/components/tab/SearchInput.vue b/src/components/tab/SearchInput.vue index 969216910..ec71e7a43 100644 --- a/src/components/tab/SearchInput.vue +++ b/src/components/tab/SearchInput.vue @@ -196,9 +196,8 @@ export default { if (this.isStateLoading) { if (this.isSmartPicker) { workPackage = await workpackageHelper.getAdditionalMetaData(workPackage) - if (this.isStateLoading) { - this.searchResults.push(workPackage) - } + this.searchResults.push(workPackage) + } else { workPackage.fileId = fileId workPackage = await workpackageHelper.getAdditionalMetaData(workPackage) diff --git a/src/views/WorkPackagePickerElement.vue b/src/views/WorkPackagePickerElement.vue index 8ef534a41..096caa9a1 100644 --- a/src/views/WorkPackagePickerElement.vue +++ b/src/views/WorkPackagePickerElement.vue @@ -68,12 +68,6 @@ export default { isAdminConfigOk: loadState('integration_openproject', 'admin-config-status'), }), - computed: { - }, - - mounted() { - }, - methods: { onSubmit(data) { this.$emit('submit', data)