mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 11:08:04 +00:00 
			
		
		
		
	chore: Linear integration fixes (#9538)
This commit is contained in:
		| @@ -99,8 +99,10 @@ export default { | ||||
|     onMouseUp() { | ||||
|       if (this.mousedDownOnBackdrop) { | ||||
|         this.mousedDownOnBackdrop = false; | ||||
|         if (this.closeOnBackdropClick) { | ||||
|           this.onClose(); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| <script setup> | ||||
| import { ref, computed } from 'vue'; | ||||
| import { debounce } from '@chatwoot/utils'; | ||||
| import { picoSearch } from '@scmmishra/pico-search'; | ||||
| import ListItemButton from './DropdownListItemButton.vue'; | ||||
| import DropdownSearch from './DropdownSearch.vue'; | ||||
| import DropdownEmptyState from './DropdownEmptyState.vue'; | ||||
| import DropdownLoadingState from './DropdownLoadingState.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   listItems: { | ||||
| @@ -26,16 +28,24 @@ const props = defineProps({ | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   isLoading: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   loadingPlaceholder: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emits = defineEmits(['on-search']); | ||||
|  | ||||
| const searchTerm = ref(''); | ||||
|  | ||||
| const onSearch = value => { | ||||
| const onSearch = debounce(value => { | ||||
|   searchTerm.value = value; | ||||
|   emits('on-search', value); | ||||
| }; | ||||
| }, 300); | ||||
|  | ||||
| const filteredListItems = computed(() => { | ||||
|   if (!searchTerm.value) return props.listItems; | ||||
| @@ -50,6 +60,16 @@ const isFilterActive = id => { | ||||
|   if (!props.activeFilterId) return false; | ||||
|   return id === props.activeFilterId; | ||||
| }; | ||||
|  | ||||
| const shouldShowLoadingState = computed(() => { | ||||
|   return ( | ||||
|     props.isLoading && isDropdownListEmpty.value && props.loadingPlaceholder | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| const shouldShowEmptyState = computed(() => { | ||||
|   return !props.isLoading && isDropdownListEmpty.value; | ||||
| }); | ||||
| </script> | ||||
| <template> | ||||
|   <div | ||||
| @@ -67,8 +87,12 @@ const isFilterActive = id => { | ||||
|       /> | ||||
|     </slot> | ||||
|     <slot name="listItem"> | ||||
|       <dropdown-loading-state | ||||
|         v-if="shouldShowLoadingState" | ||||
|         :message="loadingPlaceholder" | ||||
|       /> | ||||
|       <dropdown-empty-state | ||||
|         v-if="isDropdownListEmpty" | ||||
|         v-else-if="shouldShowEmptyState" | ||||
|         :message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')" | ||||
|       /> | ||||
|       <list-item-button | ||||
|   | ||||
| @@ -0,0 +1,15 @@ | ||||
| <script setup> | ||||
| defineProps({ | ||||
|   message: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
| <template> | ||||
|   <div | ||||
|     class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300" | ||||
|   > | ||||
|     {{ message }} | ||||
|   </div> | ||||
| </template> | ||||
| @@ -39,7 +39,7 @@ const toggleShowAllNRT = () => { | ||||
| </script> | ||||
| <template> | ||||
|   <div | ||||
|     class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto" | ||||
|     class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto" | ||||
|   > | ||||
|     <span class="text-sm font-medium text-slate-900 dark:text-slate-25"> | ||||
|       {{ $t('SLA.EVENTS.TITLE') }} | ||||
|   | ||||
| @@ -107,6 +107,7 @@ import { useI18n } from 'dashboard/composables/useI18n'; | ||||
| import { useAlert } from 'dashboard/composables'; | ||||
| import LinearAPI from 'dashboard/api/integrations/linear'; | ||||
| import validations from './validations'; | ||||
| import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   accountId: { | ||||
| @@ -140,6 +141,14 @@ const priorities = [ | ||||
|   { id: 4, name: 'Low' }, | ||||
| ]; | ||||
|  | ||||
| const statusDesiredOrder = [ | ||||
|   'Backlog', | ||||
|   'Todo', | ||||
|   'In Progress', | ||||
|   'Done', | ||||
|   'Canceled', | ||||
| ]; | ||||
|  | ||||
| const isCreating = ref(false); | ||||
| const inputStyles = { borderRadius: '12px', fontSize: '14px' }; | ||||
|  | ||||
| @@ -177,7 +186,11 @@ const getTeams = async () => { | ||||
|     const response = await LinearAPI.getTeams(); | ||||
|     teams.value = response.data; | ||||
|   } catch (error) { | ||||
|     useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')); | ||||
|     const errorMessage = parseLinearAPIErrorResponse( | ||||
|       error, | ||||
|       t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR') | ||||
|     ); | ||||
|     useAlert(errorMessage); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @@ -186,12 +199,16 @@ const getTeamEntities = async () => { | ||||
|     const response = await LinearAPI.getTeamEntities(formState.teamId); | ||||
|     assignees.value = response.data.users; | ||||
|     labels.value = response.data.labels; | ||||
|     statuses.value = response.data.states; | ||||
|     projects.value = response.data.projects; | ||||
|     statuses.value = statusDesiredOrder | ||||
|       .map(name => response.data.states.find(status => status.name === name)) | ||||
|       .filter(Boolean); | ||||
|   } catch (error) { | ||||
|     useAlert( | ||||
|     const errorMessage = parseLinearAPIErrorResponse( | ||||
|       error, | ||||
|       t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR') | ||||
|     ); | ||||
|     useAlert(errorMessage); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @@ -226,7 +243,11 @@ const createIssue = async () => { | ||||
|     useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS')); | ||||
|     onClose(); | ||||
|   } catch (error) { | ||||
|     useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')); | ||||
|     const errorMessage = parseLinearAPIErrorResponse( | ||||
|       error, | ||||
|       t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR') | ||||
|     ); | ||||
|     useAlert(errorMessage); | ||||
|   } finally { | ||||
|     isCreating.value = false; | ||||
|   } | ||||
|   | ||||
| @@ -56,7 +56,7 @@ const unlinkIssue = () => { | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="absolute flex flex-col items-start bg-ash-50 dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto" | ||||
|     class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto" | ||||
|   > | ||||
|     <div class="flex flex-col w-full"> | ||||
|       <issue-header | ||||
|   | ||||
| @@ -16,9 +16,16 @@ | ||||
|         variant="clear" | ||||
|         color-scheme="secondary" | ||||
|         class="h-[24px]" | ||||
|         :is-loading="isUnlinking" | ||||
|         @click="unlinkIssue" | ||||
|       > | ||||
|         <fluent-icon icon="unlink" size="12" type="outline" icon-lib="lucide" /> | ||||
|         <fluent-icon | ||||
|           v-if="!isUnlinking" | ||||
|           icon="unlink" | ||||
|           size="12" | ||||
|           type="outline" | ||||
|           icon-lib="lucide" | ||||
|         /> | ||||
|       </woot-button> | ||||
|       <woot-button | ||||
|         variant="clear" | ||||
| @@ -33,6 +40,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { inject } from 'vue'; | ||||
| const props = defineProps({ | ||||
|   identifier: { | ||||
|     type: String, | ||||
| @@ -44,6 +52,8 @@ const props = defineProps({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const isUnlinking = inject('isUnlinking'); | ||||
|  | ||||
| const emit = defineEmits(['unlink-issue']); | ||||
|  | ||||
| const unlinkIssue = () => { | ||||
|   | ||||
| @@ -16,9 +16,11 @@ | ||||
|           :show-clear-filter="false" | ||||
|           :list-items="issues" | ||||
|           :active-filter-id="selectedOption.id" | ||||
|           :is-loading="isFetching" | ||||
|           :input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')" | ||||
|           :loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')" | ||||
|           enable-search | ||||
|           class="left-0 flex flex-col w-full overflow-y-auto h-fit max-h-[160px] md:left-auto md:right-0 top-10" | ||||
|           class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10" | ||||
|           @on-search="onSearch" | ||||
|           @click="onSelectIssue" | ||||
|         /> | ||||
| @@ -50,6 +52,7 @@ import { useAlert } from 'dashboard/composables'; | ||||
| import LinearAPI from 'dashboard/api/integrations/linear'; | ||||
| import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue'; | ||||
| import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue'; | ||||
| import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   conversationId: { | ||||
| @@ -97,6 +100,7 @@ const onClose = () => { | ||||
| }; | ||||
|  | ||||
| const onSearch = async value => { | ||||
|   issues.value = []; | ||||
|   if (!value) return; | ||||
|   searchQuery.value = value; | ||||
|   try { | ||||
| @@ -107,7 +111,11 @@ const onSearch = async value => { | ||||
|       name: `${issue.identifier} ${issue.title}`, | ||||
|     })); | ||||
|   } catch (error) { | ||||
|     useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')); | ||||
|     const errorMessage = parseLinearAPIErrorResponse( | ||||
|       error, | ||||
|       t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR') | ||||
|     ); | ||||
|     useAlert(errorMessage); | ||||
|   } finally { | ||||
|     isFetching.value = false; | ||||
|   } | ||||
| @@ -123,7 +131,11 @@ const linkIssue = async () => { | ||||
|     issues.value = []; | ||||
|     onClose(); | ||||
|   } catch (error) { | ||||
|     useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')); | ||||
|     const errorMessage = parseLinearAPIErrorResponse( | ||||
|       error, | ||||
|       t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR') | ||||
|     ); | ||||
|     useAlert(errorMessage); | ||||
|   } finally { | ||||
|     isLinking.value = false; | ||||
|   } | ||||
|   | ||||
| @@ -27,7 +27,8 @@ | ||||
|     <woot-modal | ||||
|       :show.sync="shouldShowPopup" | ||||
|       :on-close="closePopup" | ||||
|       class="!items-start [&>div]:!top-12" | ||||
|       :close-on-backdrop-click="false" | ||||
|       class="!items-start [&>div]:!top-12 [&>div]:sticky" | ||||
|     > | ||||
|       <create-or-link-issue | ||||
|         :conversation="conversation" | ||||
| @@ -39,13 +40,18 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, ref, onMounted, watch } from 'vue'; | ||||
| import { computed, ref, onMounted, watch, defineComponent, provide } from 'vue'; | ||||
| import { useAlert } from 'dashboard/composables'; | ||||
| import { useStoreGetters } from 'dashboard/composables/store'; | ||||
| import { useI18n } from 'dashboard/composables/useI18n'; | ||||
| import LinearAPI from 'dashboard/api/integrations/linear'; | ||||
| import CreateOrLinkIssue from './CreateOrLinkIssue.vue'; | ||||
| import Issue from './Issue.vue'; | ||||
| import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api'; | ||||
|  | ||||
| defineComponent({ | ||||
|   name: 'Linear', | ||||
| }); | ||||
|  | ||||
| const props = defineProps({ | ||||
|   conversationId: { | ||||
| @@ -60,6 +66,9 @@ const { t } = useI18n(); | ||||
| const linkedIssue = ref(null); | ||||
| const shouldShow = ref(false); | ||||
| const shouldShowPopup = ref(false); | ||||
| const isUnlinking = ref(false); | ||||
|  | ||||
| provide('isUnlinking', isUnlinking); | ||||
|  | ||||
| const currentAccountId = getters.getCurrentAccountId; | ||||
|  | ||||
| @@ -80,17 +89,28 @@ const loadLinkedIssue = async () => { | ||||
|     const issues = response.data; | ||||
|     linkedIssue.value = issues && issues.length ? issues[0] : null; | ||||
|   } catch (error) { | ||||
|     useAlert(error?.message || t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')); | ||||
|     const errorMessage = parseLinearAPIErrorResponse( | ||||
|       error, | ||||
|       t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR') | ||||
|     ); | ||||
|     useAlert(errorMessage); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const unlinkIssue = async linkId => { | ||||
|   try { | ||||
|     isUnlinking.value = true; | ||||
|     await LinearAPI.unlinkIssue(linkId); | ||||
|     linkedIssue.value = null; | ||||
|     useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS')); | ||||
|   } catch (error) { | ||||
|     useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')); | ||||
|     const errorMessage = parseLinearAPIErrorResponse( | ||||
|       error, | ||||
|       t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR') | ||||
|     ); | ||||
|     useAlert(errorMessage); | ||||
|   } finally { | ||||
|     isUnlinking.value = false; | ||||
|   } | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -218,7 +218,7 @@ | ||||
|         "ERROR": "There was an error fetching the linear issues, please try again", | ||||
|         "LINK_SUCCESS": "Issue linked successfully", | ||||
|         "LINK_ERROR": "There was an error linking the issue, please try again", | ||||
|         "LINK_TITLE": "#%{conversationId}: %{name}" | ||||
|         "LINK_TITLE": "Conversation (#%{conversationId}) with %{name}" | ||||
|       }, | ||||
|       "ADD_OR_LINK": { | ||||
|         "TITLE": "Create/link linear issue", | ||||
| @@ -235,22 +235,34 @@ | ||||
|           }, | ||||
|           "TEAM": { | ||||
|             "LABEL": "Team", | ||||
|             "PLACEHOLDER": "Select team", | ||||
|             "SEARCH": "Search team", | ||||
|             "REQUIRED_ERROR": "Team is required" | ||||
|           }, | ||||
|           "ASSIGNEE": { | ||||
|             "LABEL": "Assignee" | ||||
|             "LABEL": "Assignee", | ||||
|             "PLACEHOLDER": "Select assignee", | ||||
|             "SEARCH": "Search assignee" | ||||
|           }, | ||||
|           "PRIORITY": { | ||||
|             "LABEL": "Priority" | ||||
|             "LABEL": "Priority", | ||||
|             "PLACEHOLDER": "Select priority", | ||||
|             "SEARCH": "Search priority" | ||||
|           }, | ||||
|           "LABEL": { | ||||
|             "LABEL": "Label" | ||||
|             "LABEL": "Label", | ||||
|             "PLACEHOLDER": "Select label", | ||||
|             "SEARCH": "Search label" | ||||
|           }, | ||||
|           "STATUS": { | ||||
|             "LABEL": "Status" | ||||
|             "LABEL": "Status", | ||||
|             "PLACEHOLDER": "Select status", | ||||
|             "SEARCH": "Search status" | ||||
|           }, | ||||
|           "PROJECT": { | ||||
|             "LABEL": "Project" | ||||
|             "LABEL": "Project", | ||||
|             "PLACEHOLDER": "Select project", | ||||
|             "SEARCH": "Search project" | ||||
|           } | ||||
|         }, | ||||
|         "CREATE": "Create", | ||||
|   | ||||
| @@ -96,3 +96,9 @@ export const throwErrorMessage = error => { | ||||
|   const errorMessage = parseAPIErrorResponse(error); | ||||
|   throw new Error(errorMessage); | ||||
| }; | ||||
|  | ||||
| export const parseLinearAPIErrorResponse = (error, defaultMessage) => { | ||||
|   const errorData = error.response.data; | ||||
|   const errorMessage = errorData?.error?.errors?.[0]?.message || defaultMessage; | ||||
|   return errorMessage; | ||||
| }; | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { | ||||
|   parseAPIErrorResponse, | ||||
|   setLoadingStatus, | ||||
|   throwErrorMessage, | ||||
|   parseLinearAPIErrorResponse, | ||||
| } from '../api'; | ||||
|  | ||||
| describe('#getLoadingStatus', () => { | ||||
| @@ -49,3 +50,26 @@ describe('#throwErrorMessage', () => { | ||||
|     expect(errorFn).toThrow('Error Message [message]'); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| describe('#parseLinearAPIErrorResponse', () => { | ||||
|   it('returns correct values', () => { | ||||
|     expect( | ||||
|       parseLinearAPIErrorResponse( | ||||
|         { | ||||
|           response: { | ||||
|             data: { | ||||
|               error: { | ||||
|                 errors: [ | ||||
|                   { | ||||
|                     message: 'Error Message [message]', | ||||
|                   }, | ||||
|                 ], | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|         'Default Message' | ||||
|       ) | ||||
|     ).toBe('Error Message [message]'); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -3,7 +3,7 @@ module Linear::Mutations | ||||
|     case value | ||||
|     when String | ||||
|       # Strings must be enclosed in double quotes | ||||
|       "\"#{value}\"" | ||||
|       "\"#{value.gsub("\n", '\\n')}\"" | ||||
|     when Array | ||||
|       # Arrays need to be recursively converted | ||||
|       "[#{value.map { |v| graphql_value(v) }.join(', ')}]" | ||||
|   | ||||
| @@ -148,6 +148,23 @@ describe Linear do | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when the description is markdown' do | ||||
|       let(:description) { 'Cmd/Ctrl` `K` **is our most powerful feature.** \n\nUse it to search for or take any action in the app' } | ||||
|  | ||||
|       before do | ||||
|         stub_request(:post, url) | ||||
|           .to_return(status: 200, body: { success: true, | ||||
|                                           data: { issueCreate: { id: 'issue1', title: 'Title', | ||||
|                                                                  description: description } } }.to_json, headers: headers) | ||||
|       end | ||||
|  | ||||
|       it 'creates an issue' do | ||||
|         response = linear_client.create_issue(params) | ||||
|         expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title', | ||||
|                                                     'description' => description } }) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when the API response is an error' do | ||||
|       before do | ||||
|         stub_request(:post, url) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth