chore: Linear integration fixes (#9538)

This commit is contained in:
Muhsin Keloth
2024-05-29 11:28:13 +05:30
committed by GitHub
parent 59b912f22c
commit a55fffab3a
14 changed files with 188 additions and 25 deletions

View File

@@ -99,7 +99,9 @@ export default {
onMouseUp() { onMouseUp() {
if (this.mousedDownOnBackdrop) { if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false; this.mousedDownOnBackdrop = false;
this.onClose(); if (this.closeOnBackdropClick) {
this.onClose();
}
} }
}, },
}, },

View File

@@ -1,9 +1,11 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { picoSearch } from '@scmmishra/pico-search'; import { picoSearch } from '@scmmishra/pico-search';
import ListItemButton from './DropdownListItemButton.vue'; import ListItemButton from './DropdownListItemButton.vue';
import DropdownSearch from './DropdownSearch.vue'; import DropdownSearch from './DropdownSearch.vue';
import DropdownEmptyState from './DropdownEmptyState.vue'; import DropdownEmptyState from './DropdownEmptyState.vue';
import DropdownLoadingState from './DropdownLoadingState.vue';
const props = defineProps({ const props = defineProps({
listItems: { listItems: {
@@ -26,16 +28,24 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isLoading: {
type: Boolean,
default: false,
},
loadingPlaceholder: {
type: String,
default: '',
},
}); });
const emits = defineEmits(['on-search']); const emits = defineEmits(['on-search']);
const searchTerm = ref(''); const searchTerm = ref('');
const onSearch = value => { const onSearch = debounce(value => {
searchTerm.value = value; searchTerm.value = value;
emits('on-search', value); emits('on-search', value);
}; }, 300);
const filteredListItems = computed(() => { const filteredListItems = computed(() => {
if (!searchTerm.value) return props.listItems; if (!searchTerm.value) return props.listItems;
@@ -50,6 +60,16 @@ const isFilterActive = id => {
if (!props.activeFilterId) return false; if (!props.activeFilterId) return false;
return id === props.activeFilterId; return id === props.activeFilterId;
}; };
const shouldShowLoadingState = computed(() => {
return (
props.isLoading && isDropdownListEmpty.value && props.loadingPlaceholder
);
});
const shouldShowEmptyState = computed(() => {
return !props.isLoading && isDropdownListEmpty.value;
});
</script> </script>
<template> <template>
<div <div
@@ -67,8 +87,12 @@ const isFilterActive = id => {
/> />
</slot> </slot>
<slot name="listItem"> <slot name="listItem">
<dropdown-loading-state
v-if="shouldShowLoadingState"
:message="loadingPlaceholder"
/>
<dropdown-empty-state <dropdown-empty-state
v-if="isDropdownListEmpty" v-else-if="shouldShowEmptyState"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')" :message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
/> />
<list-item-button <list-item-button

View File

@@ -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>

View File

@@ -39,7 +39,7 @@ const toggleShowAllNRT = () => {
</script> </script>
<template> <template>
<div <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"> <span class="text-sm font-medium text-slate-900 dark:text-slate-25">
{{ $t('SLA.EVENTS.TITLE') }} {{ $t('SLA.EVENTS.TITLE') }}

View File

@@ -107,6 +107,7 @@ import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear'; import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations'; import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({ const props = defineProps({
accountId: { accountId: {
@@ -140,6 +141,14 @@ const priorities = [
{ id: 4, name: 'Low' }, { id: 4, name: 'Low' },
]; ];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false); const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' }; const inputStyles = { borderRadius: '12px', fontSize: '14px' };
@@ -177,7 +186,11 @@ const getTeams = async () => {
const response = await LinearAPI.getTeams(); const response = await LinearAPI.getTeams();
teams.value = response.data; teams.value = response.data;
} catch (error) { } 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); const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users; assignees.value = response.data.users;
labels.value = response.data.labels; labels.value = response.data.labels;
statuses.value = response.data.states;
projects.value = response.data.projects; projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) { } catch (error) {
useAlert( const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_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')); useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
onClose(); onClose();
} catch (error) { } 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 { } finally {
isCreating.value = false; isCreating.value = false;
} }

View File

@@ -56,7 +56,7 @@ const unlinkIssue = () => {
<template> <template>
<div <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"> <div class="flex flex-col w-full">
<issue-header <issue-header

View File

@@ -16,9 +16,16 @@
variant="clear" variant="clear"
color-scheme="secondary" color-scheme="secondary"
class="h-[24px]" class="h-[24px]"
:is-loading="isUnlinking"
@click="unlinkIssue" @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>
<woot-button <woot-button
variant="clear" variant="clear"
@@ -33,6 +40,7 @@
</template> </template>
<script setup> <script setup>
import { inject } from 'vue';
const props = defineProps({ const props = defineProps({
identifier: { identifier: {
type: String, type: String,
@@ -44,6 +52,8 @@ const props = defineProps({
}, },
}); });
const isUnlinking = inject('isUnlinking');
const emit = defineEmits(['unlink-issue']); const emit = defineEmits(['unlink-issue']);
const unlinkIssue = () => { const unlinkIssue = () => {

View File

@@ -16,9 +16,11 @@
:show-clear-filter="false" :show-clear-filter="false"
:list-items="issues" :list-items="issues"
:active-filter-id="selectedOption.id" :active-filter-id="selectedOption.id"
:is-loading="isFetching"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')" :input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
enable-search 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" @on-search="onSearch"
@click="onSelectIssue" @click="onSelectIssue"
/> />
@@ -50,6 +52,7 @@ import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear'; import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue'; import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue'; import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({ const props = defineProps({
conversationId: { conversationId: {
@@ -97,6 +100,7 @@ const onClose = () => {
}; };
const onSearch = async value => { const onSearch = async value => {
issues.value = [];
if (!value) return; if (!value) return;
searchQuery.value = value; searchQuery.value = value;
try { try {
@@ -107,7 +111,11 @@ const onSearch = async value => {
name: `${issue.identifier} ${issue.title}`, name: `${issue.identifier} ${issue.title}`,
})); }));
} catch (error) { } catch (error) {
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')); const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
);
useAlert(errorMessage);
} finally { } finally {
isFetching.value = false; isFetching.value = false;
} }
@@ -123,7 +131,11 @@ const linkIssue = async () => {
issues.value = []; issues.value = [];
onClose(); onClose();
} catch (error) { } catch (error) {
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')); const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
);
useAlert(errorMessage);
} finally { } finally {
isLinking.value = false; isLinking.value = false;
} }

View File

@@ -27,7 +27,8 @@
<woot-modal <woot-modal
:show.sync="shouldShowPopup" :show.sync="shouldShowPopup"
:on-close="closePopup" :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 <create-or-link-issue
:conversation="conversation" :conversation="conversation"
@@ -39,13 +40,18 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted, watch } from 'vue'; import { computed, ref, onMounted, watch, defineComponent, provide } from 'vue';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n'; import { useI18n } from 'dashboard/composables/useI18n';
import LinearAPI from 'dashboard/api/integrations/linear'; import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue'; import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue'; import Issue from './Issue.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
defineComponent({
name: 'Linear',
});
const props = defineProps({ const props = defineProps({
conversationId: { conversationId: {
@@ -60,6 +66,9 @@ const { t } = useI18n();
const linkedIssue = ref(null); const linkedIssue = ref(null);
const shouldShow = ref(false); const shouldShow = ref(false);
const shouldShowPopup = ref(false); const shouldShowPopup = ref(false);
const isUnlinking = ref(false);
provide('isUnlinking', isUnlinking);
const currentAccountId = getters.getCurrentAccountId; const currentAccountId = getters.getCurrentAccountId;
@@ -80,17 +89,28 @@ const loadLinkedIssue = async () => {
const issues = response.data; const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null; linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) { } 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 => { const unlinkIssue = async linkId => {
try { try {
isUnlinking.value = true;
await LinearAPI.unlinkIssue(linkId); await LinearAPI.unlinkIssue(linkId);
linkedIssue.value = null; linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS')); useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) { } 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;
} }
}; };

View File

@@ -218,7 +218,7 @@
"ERROR": "There was an error fetching the linear issues, please try again", "ERROR": "There was an error fetching the linear issues, please try again",
"LINK_SUCCESS": "Issue linked successfully", "LINK_SUCCESS": "Issue linked successfully",
"LINK_ERROR": "There was an error linking the issue, please try again", "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": { "ADD_OR_LINK": {
"TITLE": "Create/link linear issue", "TITLE": "Create/link linear issue",
@@ -235,22 +235,34 @@
}, },
"TEAM": { "TEAM": {
"LABEL": "Team", "LABEL": "Team",
"PLACEHOLDER": "Select team",
"SEARCH": "Search team",
"REQUIRED_ERROR": "Team is required" "REQUIRED_ERROR": "Team is required"
}, },
"ASSIGNEE": { "ASSIGNEE": {
"LABEL": "Assignee" "LABEL": "Assignee",
"PLACEHOLDER": "Select assignee",
"SEARCH": "Search assignee"
}, },
"PRIORITY": { "PRIORITY": {
"LABEL": "Priority" "LABEL": "Priority",
"PLACEHOLDER": "Select priority",
"SEARCH": "Search priority"
}, },
"LABEL": { "LABEL": {
"LABEL": "Label" "LABEL": "Label",
"PLACEHOLDER": "Select label",
"SEARCH": "Search label"
}, },
"STATUS": { "STATUS": {
"LABEL": "Status" "LABEL": "Status",
"PLACEHOLDER": "Select status",
"SEARCH": "Search status"
}, },
"PROJECT": { "PROJECT": {
"LABEL": "Project" "LABEL": "Project",
"PLACEHOLDER": "Select project",
"SEARCH": "Search project"
} }
}, },
"CREATE": "Create", "CREATE": "Create",

View File

@@ -96,3 +96,9 @@ export const throwErrorMessage = error => {
const errorMessage = parseAPIErrorResponse(error); const errorMessage = parseAPIErrorResponse(error);
throw new Error(errorMessage); throw new Error(errorMessage);
}; };
export const parseLinearAPIErrorResponse = (error, defaultMessage) => {
const errorData = error.response.data;
const errorMessage = errorData?.error?.errors?.[0]?.message || defaultMessage;
return errorMessage;
};

View File

@@ -3,6 +3,7 @@ import {
parseAPIErrorResponse, parseAPIErrorResponse,
setLoadingStatus, setLoadingStatus,
throwErrorMessage, throwErrorMessage,
parseLinearAPIErrorResponse,
} from '../api'; } from '../api';
describe('#getLoadingStatus', () => { describe('#getLoadingStatus', () => {
@@ -49,3 +50,26 @@ describe('#throwErrorMessage', () => {
expect(errorFn).toThrow('Error Message [message]'); 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]');
});
});

View File

@@ -3,7 +3,7 @@ module Linear::Mutations
case value case value
when String when String
# Strings must be enclosed in double quotes # Strings must be enclosed in double quotes
"\"#{value}\"" "\"#{value.gsub("\n", '\\n')}\""
when Array when Array
# Arrays need to be recursively converted # Arrays need to be recursively converted
"[#{value.map { |v| graphql_value(v) }.join(', ')}]" "[#{value.map { |v| graphql_value(v) }.join(', ')}]"

View File

@@ -148,6 +148,23 @@ describe Linear do
end end
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 context 'when the API response is an error' do
before do before do
stub_request(:post, url) stub_request(:post, url)