mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 11:37:58 +00:00
feat: Linear front end (#9491)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="resolve-actions relative flex items-center justify-end">
|
||||
<div class="relative flex items-center justify-end resolve-actions">
|
||||
<div class="button-group">
|
||||
<woot-button
|
||||
v-if="isOpen"
|
||||
|
||||
@@ -19,7 +19,7 @@ const props = defineProps({
|
||||
default: '',
|
||||
},
|
||||
activeFilterId: {
|
||||
type: Number,
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
showClearFilter: {
|
||||
@@ -28,10 +28,13 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['on-search']);
|
||||
|
||||
const searchTerm = ref('');
|
||||
|
||||
const onSearch = value => {
|
||||
searchTerm.value = value;
|
||||
emits('on-search', value);
|
||||
};
|
||||
|
||||
const filteredListItems = computed(() => {
|
||||
@@ -55,7 +58,7 @@ const isFilterActive = id => {
|
||||
>
|
||||
<slot name="search">
|
||||
<dropdown-search
|
||||
v-if="enableSearch && listItems.length"
|
||||
v-if="enableSearch"
|
||||
:input-value="searchTerm"
|
||||
:input-placeholder="inputPlaceholder"
|
||||
:show-clear-filter="showClearFilter"
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
||||
<linear
|
||||
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
<more-actions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +93,8 @@ import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Linear from './linear/index.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -97,6 +103,7 @@ export default {
|
||||
MoreActions,
|
||||
Thumbnail,
|
||||
SLACardLabel,
|
||||
Linear,
|
||||
},
|
||||
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -121,6 +128,9 @@ export default {
|
||||
...mapGetters({
|
||||
uiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
currentChat: 'getSelectedChat',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
appIntegrations: 'integrations/getAppIntegrations',
|
||||
}),
|
||||
chatMetadata() {
|
||||
return this.chat.meta;
|
||||
@@ -178,6 +188,17 @@ export default {
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
isLinearIntegrationEnabled() {
|
||||
return this.appIntegrations.find(
|
||||
integration => integration.id === 'linear' && !!integration.hooks.length
|
||||
);
|
||||
},
|
||||
isLinearFeatureEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.LINEAR
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div @submit.prevent="onSubmit">
|
||||
<woot-input
|
||||
v-model="formState.title"
|
||||
:class="{ error: v$.title.$error }"
|
||||
class="w-full"
|
||||
:styles="{ ...inputStyles, padding: '6px 12px' }"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
|
||||
:placeholder="
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
|
||||
"
|
||||
:error="nameError"
|
||||
@input="v$.title.$touch"
|
||||
/>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
|
||||
<textarea
|
||||
v-model="formState.description"
|
||||
:style="{ ...inputStyles, padding: '8px 12px' }"
|
||||
rows="3"
|
||||
class="text-sm"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label :class="{ error: v$.teamId.$error }">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
|
||||
<select
|
||||
v-model="formState.teamId"
|
||||
:style="inputStyles"
|
||||
@change="onChangeTeam"
|
||||
>
|
||||
<option v-for="item in teams" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="v$.teamId.$error" class="message">
|
||||
{{ teamError }}
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
|
||||
<select v-model="formState.assigneeId" :style="inputStyles">
|
||||
<option v-for="item in assignees" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
|
||||
<select v-model="formState.labelId" :style="inputStyles">
|
||||
<option v-for="item in labels" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
|
||||
<select v-model="formState.priority" :style="inputStyles">
|
||||
<option v-for="item in priorities" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
|
||||
<select v-model="formState.projectId" :style="inputStyles">
|
||||
<option v-for="item in projects" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
|
||||
<select v-model="formState.stateId" :style="inputStyles">
|
||||
<option v-for="item in statuses" :key="item.name" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex items-center justify-end w-full gap-2 mt-8">
|
||||
<woot-button
|
||||
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
|
||||
@click.prevent="onClose"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-disabled="isSubmitDisabled"
|
||||
class="px-4 rounded-xl"
|
||||
:is-loading="isCreating"
|
||||
@click.prevent="createIssue"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed, onMounted, ref } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import validations from './validations';
|
||||
|
||||
const props = defineProps({
|
||||
accountId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const teams = ref([]);
|
||||
const assignees = ref([]);
|
||||
const projects = ref([]);
|
||||
const labels = ref([]);
|
||||
const statuses = ref([]);
|
||||
|
||||
const priorities = [
|
||||
{ id: 0, name: 'No priority' },
|
||||
{ id: 1, name: 'Urgent' },
|
||||
{ id: 2, name: 'High' },
|
||||
{ id: 3, name: 'Normal' },
|
||||
{ id: 4, name: 'Low' },
|
||||
];
|
||||
|
||||
const isCreating = ref(false);
|
||||
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
|
||||
|
||||
const formState = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
teamId: '',
|
||||
assigneeId: '',
|
||||
labelId: '',
|
||||
stateId: '',
|
||||
priority: '',
|
||||
projectId: '',
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(validations, formState);
|
||||
|
||||
const isSubmitDisabled = computed(
|
||||
() => v$.value.title.$invalid || isCreating.value
|
||||
);
|
||||
const nameError = computed(() =>
|
||||
v$.value.title.$error
|
||||
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
|
||||
: ''
|
||||
);
|
||||
const teamError = computed(() =>
|
||||
v$.value.teamId.$error
|
||||
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const onClose = () => emit('close');
|
||||
|
||||
const getTeams = async () => {
|
||||
try {
|
||||
const response = await LinearAPI.getTeams();
|
||||
teams.value = response.data;
|
||||
} catch (error) {
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const getTeamEntities = async () => {
|
||||
try {
|
||||
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;
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeTeam = event => {
|
||||
formState.teamId = event.target.value;
|
||||
formState.assigneeId = '';
|
||||
formState.stateId = '';
|
||||
formState.labelId = '';
|
||||
getTeamEntities();
|
||||
};
|
||||
|
||||
const createIssue = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const payload = {
|
||||
team_id: formState.teamId,
|
||||
title: formState.title,
|
||||
description: formState.description || undefined,
|
||||
assignee_id: formState.assigneeId || undefined,
|
||||
project_id: formState.projectId || undefined,
|
||||
state_id: formState.stateId || undefined,
|
||||
priority: formState.priority || undefined,
|
||||
label_ids: formState.labelId ? [formState.labelId] : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
isCreating.value = true;
|
||||
const response = await LinearAPI.createIssue(payload);
|
||||
const { id: issueId } = response.data;
|
||||
await LinearAPI.link_issue(props.conversationId, issueId);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR'));
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(getTeams);
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<woot-modal-header
|
||||
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
|
||||
:header-content="
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="flex flex-col px-8 pb-4">
|
||||
<woot-tabs
|
||||
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
|
||||
:index="selectedTabIndex"
|
||||
@change="onClickTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
/>
|
||||
</woot-tabs>
|
||||
</div>
|
||||
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
|
||||
<create-issue
|
||||
:account-id="accountId"
|
||||
:conversation-id="conversationId"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col px-8 pb-4">
|
||||
<link-issue :conversation-id="conversationId" @close="onClose" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { ref } from 'vue';
|
||||
import LinkIssue from './LinkIssue.vue';
|
||||
import CreateIssue from './CreateIssue.vue';
|
||||
|
||||
defineProps({
|
||||
accountId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedTabIndex = ref(0);
|
||||
|
||||
const emits = defineEmits(['close']);
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
key: 0,
|
||||
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
|
||||
},
|
||||
]);
|
||||
const onClose = () => {
|
||||
emits('close');
|
||||
};
|
||||
|
||||
const onClickTabChange = index => {
|
||||
selectedTabIndex.value = index;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
|
||||
import IssueHeader from './IssueHeader.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const priorityMap = {
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
issue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
linkId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlink-issue']);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const { createdAt } = props.issue;
|
||||
return format(new Date(createdAt), 'hh:mm a, MMM dd');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const assigneeDetails = props.issue.assignee;
|
||||
|
||||
if (!assigneeDetails) return null;
|
||||
const { name, avatarUrl } = assigneeDetails;
|
||||
|
||||
return {
|
||||
name,
|
||||
thumbnail: avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const labels = computed(() => {
|
||||
return props.issue.labels?.nodes || [];
|
||||
});
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
return priorityMap[props.issue.priority];
|
||||
});
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlink-issue', props.linkId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<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"
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<issue-header
|
||||
:identifier="issue.identifier"
|
||||
:link-id="linkId"
|
||||
:issue-url="issue.url"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<span class="mt-2 text-sm font-medium text-ash-900">
|
||||
{{ issue.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-ash-800 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-row items-center h-6 gap-2">
|
||||
<user-avatar-with-name v-if="assignee" :user="assignee" class="py-1" />
|
||||
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
|
||||
<div class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
icon="status"
|
||||
size="14"
|
||||
:style="{ color: issue.state.color }"
|
||||
/>
|
||||
<h6 class="text-xs text-ash-900">
|
||||
{{ issue.state.name }}
|
||||
</h6>
|
||||
</div>
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
:icon="`priority-${priorityLabel.toLowerCase()}`"
|
||||
size="14"
|
||||
view-box="0 0 12 12"
|
||||
/>
|
||||
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
|
||||
<woot-label
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:title="label.name"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-ash-800">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
|
||||
createdAt: formattedDate,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div
|
||||
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
class="h-[24px]"
|
||||
@click="unlinkIssue"
|
||||
>
|
||||
<fluent-icon icon="unlink" size="12" type="outline" icon-lib="lucide" />
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
class="h-[24px]"
|
||||
color-scheme="secondary"
|
||||
@click="openIssue"
|
||||
>
|
||||
<fluent-icon icon="arrow-up-right" size="14" />
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
identifier: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
issueUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlink-issue']);
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlink-issue');
|
||||
};
|
||||
|
||||
const openIssue = () => {
|
||||
window.open(props.issueUrl, '_blank');
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between"
|
||||
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
|
||||
>
|
||||
<filter-button
|
||||
right-icon="chevron-down"
|
||||
:button-text="linkIssueTitle"
|
||||
class="justify-between w-full bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template v-if="shouldShowDropdown" #dropdown>
|
||||
<filter-list-dropdown
|
||||
v-if="issues"
|
||||
v-on-clickaway="toggleDropdown"
|
||||
:show-clear-filter="false"
|
||||
:list-items="issues"
|
||||
:active-filter-id="selectedOption.id"
|
||||
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.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"
|
||||
@on-search="onSearch"
|
||||
@click="onSelectIssue"
|
||||
/>
|
||||
</template>
|
||||
</filter-button>
|
||||
<div class="flex items-center justify-end w-full gap-2">
|
||||
<woot-button
|
||||
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
|
||||
@click.prevent="onClose"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-disabled="isSubmitDisabled"
|
||||
class="px-4 rounded-xl"
|
||||
:is-loading="isLinking"
|
||||
@click.prevent="linkIssue"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
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';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const issues = ref([]);
|
||||
const shouldShowDropdown = ref(false);
|
||||
const selectedOption = ref({ id: null, name: '' });
|
||||
const isFetching = ref(false);
|
||||
const isLinking = ref(false);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const toggleDropdown = () => {
|
||||
issues.value = [];
|
||||
shouldShowDropdown.value = !shouldShowDropdown.value;
|
||||
};
|
||||
|
||||
const linkIssueTitle = computed(() => {
|
||||
return selectedOption.value.id
|
||||
? selectedOption.value.name
|
||||
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
|
||||
});
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
return !selectedOption.value.id || isLinking.value;
|
||||
});
|
||||
|
||||
const onSelectIssue = item => {
|
||||
selectedOption.value = item;
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emits('close');
|
||||
};
|
||||
|
||||
const onSearch = async value => {
|
||||
if (!value) return;
|
||||
searchQuery.value = value;
|
||||
try {
|
||||
isFetching.value = true;
|
||||
const response = await LinearAPI.searchIssues(value);
|
||||
issues.value = response.data.map(issue => ({
|
||||
id: issue.id,
|
||||
name: `${issue.identifier} ${issue.title}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR'));
|
||||
} finally {
|
||||
isFetching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const linkIssue = async () => {
|
||||
const { id: issueId } = selectedOption.value;
|
||||
try {
|
||||
isLinking.value = true;
|
||||
await LinearAPI.link_issue(props.conversationId, issueId);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
|
||||
searchQuery.value = '';
|
||||
issues.value = [];
|
||||
onClose();
|
||||
} catch (error) {
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR'));
|
||||
} finally {
|
||||
isLinking.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="relative" :class="{ group: linkedIssue }">
|
||||
<woot-button
|
||||
v-on-clickaway="closeIssue"
|
||||
v-tooltip="tooltipText"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
@click="openIssue"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
|
||||
{{ linkedIssue.issue.identifier }}
|
||||
</span>
|
||||
</woot-button>
|
||||
<issue
|
||||
v-if="linkedIssue"
|
||||
:issue="linkedIssue.issue"
|
||||
:link-id="linkedIssue.id"
|
||||
class="absolute right-0 top-[40px] invisible group-hover:visible"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
<woot-modal
|
||||
:show.sync="shouldShowPopup"
|
||||
:on-close="closePopup"
|
||||
class="!items-start [&>div]:!top-12"
|
||||
>
|
||||
<create-or-link-issue
|
||||
:conversation-id="conversationId"
|
||||
:account-id="currentAccountId"
|
||||
@close="closePopup"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } 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';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const linkedIssue = ref(null);
|
||||
const shouldShow = ref(false);
|
||||
const shouldShowPopup = ref(false);
|
||||
|
||||
const currentAccountId = getters.getCurrentAccountId;
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
return linkedIssue.value === null
|
||||
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
|
||||
: null;
|
||||
});
|
||||
|
||||
const loadLinkedIssue = async () => {
|
||||
linkedIssue.value = null;
|
||||
try {
|
||||
const response = await LinearAPI.getLinkedIssue(props.conversationId);
|
||||
const issues = response.data;
|
||||
linkedIssue.value = issues && issues.length ? issues[0] : null;
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkIssue = async linkId => {
|
||||
try {
|
||||
await LinearAPI.unlinkIssue(linkId);
|
||||
linkedIssue.value = null;
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.DELETE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const openIssue = () => {
|
||||
if (!linkedIssue.value) shouldShowPopup.value = true;
|
||||
shouldShow.value = true;
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
shouldShowPopup.value = false;
|
||||
loadLinkedIssue();
|
||||
};
|
||||
|
||||
const closeIssue = () => {
|
||||
shouldShow.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadLinkedIssue();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssue();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { required } from '@vuelidate/validators';
|
||||
|
||||
export default {
|
||||
title: {
|
||||
required,
|
||||
},
|
||||
teamId: {
|
||||
required,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user