mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 18:47:51 +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