mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
UBERF-11414: Integrations (#9610)
* UBERF-11414: Use common integration kind Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Allow multiple gmail Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Merge with develop Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Refactor integration kinds Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Add integrations package Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Integrations Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Integration state and config Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Add integration events Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Refresh integration states Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Rename to integration-client Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Add filter button Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Add translations for filters Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Synced channels count Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Actions Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Add isLoading for connect Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Create connection and integration Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Use social id from hulygram Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Remove old integrations Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Gmail state Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Gmail state and disconnect all handler Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Add tests Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Formatting Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Gmail sync state Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Fix rush check Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Fix gmail tests Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Fix calendar actions and config Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Calendar state Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Access config Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Github integration state Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Fix svelte errors Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Fix formatting Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Fix typos Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Fix validate Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Fix translations Signed-off-by: Artem Savchenko <armisav@gmail.com> * UBERF-11414: Retry on network issues Signed-off-by: Artem Savchenko <armisav@gmail.com> --------- Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Generated
+39
-2
@@ -466,6 +466,9 @@ importers:
|
||||
'@rush-temp/inbox-resources':
|
||||
specifier: file:./projects/inbox-resources.tgz
|
||||
version: file:projects/inbox-resources.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
'@rush-temp/integration-client':
|
||||
specifier: file:./projects/integration-client.tgz
|
||||
version: file:projects/integration-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
'@rush-temp/inventory':
|
||||
specifier: file:./projects/inventory.tgz
|
||||
version: file:projects/inventory.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
@@ -4743,6 +4746,10 @@ packages:
|
||||
resolution: {integrity: sha512-Qv3JD4silIEc3DbDzvxoBF+bW3a5/+oZXYBudlOu0Rjl83i4Rq5XOV9wWSa74ob9XlyVPvEl80OuqcEEydbS/g==, tarball: file:projects/inbox.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/integration-client@file:projects/integration-client.tgz':
|
||||
resolution: {integrity: sha512-r46KMTTe/Xy/8c7L81e3fmLX/ESI5ChiCmz+KTBVnHqeuM788FYZVdJhb+JebbXoi+lSHhqE0kWm825RhPiUEA==, tarball: file:projects/integration-client.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/inventory-assets@file:projects/inventory-assets.tgz':
|
||||
resolution: {integrity: sha512-Ad5kbORRxeNRLA+TZJOkkE657ctwFxnyGpP24QsqtwWYpi0asjNkE/niPY9GdM5nqRIkNFG+Jh375imRx38XlQ==, tarball: file:projects/inventory-assets.tgz}
|
||||
version: 0.0.0
|
||||
@@ -5312,7 +5319,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-gmail@file:projects/pod-gmail.tgz':
|
||||
resolution: {integrity: sha512-ZLnW1O8HTDfGRQLJGyMs1GO63xr5jnqZOZ36/0AjopTYWrT0xzP77bRh/J6XmjOUhDza7oGHisztULcWkyMHQg==, tarball: file:projects/pod-gmail.tgz}
|
||||
resolution: {integrity: sha512-HAat9sFoOjj5Shx3UlRU9ACh2mH4gLlp8CEkxv3gbuoQwVQsgJ0VLCe4FwUjj9DPHP8cpBTek6h75DOXbbvN3g==, tarball: file:projects/pod-gmail.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz':
|
||||
@@ -5896,7 +5903,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/telegram-resources@file:projects/telegram-resources.tgz':
|
||||
resolution: {integrity: sha512-AfBy+LDwX0PQwsO8zX0oY5lwb4VhN/KMduePtl13BpiTpHRfJH+/9CQ+zOnu/2t6UEP9yMqrr+trSIGCwX/OMg==, tarball: file:projects/telegram-resources.tgz}
|
||||
resolution: {integrity: sha512-oNF++UNPuNik4Ak7sGISighoWJtiPGG+x9+Bop7xHYpPk+j4QTobQlqDKBSAXJdXYE2fe4U64QL5vvIeZT/dDQ==, tarball: file:projects/telegram-resources.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/telegram@file:projects/telegram.tgz':
|
||||
@@ -20698,6 +20705,36 @@ snapshots:
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
'@rush-temp/integration-client@file:projects/integration-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@types/jest': 29.5.12
|
||||
'@types/node': 22.15.29
|
||||
'@types/uuid': 8.3.4
|
||||
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3)
|
||||
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3)
|
||||
cross-env: 7.0.3
|
||||
esbuild: 0.24.2
|
||||
eslint: 8.56.0
|
||||
eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.8.3)
|
||||
eslint-plugin-import: 2.29.1(eslint@8.56.0)
|
||||
eslint-plugin-n: 15.7.0(eslint@8.56.0)
|
||||
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
|
||||
jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
jest-fetch-mock: 3.0.3(encoding@0.1.13)
|
||||
prettier: 3.2.5
|
||||
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
uuid: 8.3.2
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@jest/types'
|
||||
- babel-jest
|
||||
- babel-plugin-macros
|
||||
- encoding
|
||||
- node-notifier
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
'@rush-temp/inventory-assets@file:projects/inventory-assets.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@types/jest': 29.5.12
|
||||
|
||||
+10
-11
@@ -1,4 +1,5 @@
|
||||
import { type AccountClient } from '@hcengineering/account-client'
|
||||
import { calendarIntegrationKind } from '@hcengineering/calendar'
|
||||
import {
|
||||
type PersonId,
|
||||
type WorkspaceInfoWithStatus,
|
||||
@@ -55,8 +56,6 @@ interface WorkspaceInfoProvider {
|
||||
getWorkspaceInfo: (workspaceUuid: WorkspaceUuid) => Promise<WorkspaceInfoWithStatus | undefined>
|
||||
}
|
||||
|
||||
const CALENDAR_INTEGRATION = 'google-calendar'
|
||||
|
||||
export async function performCalendarAccountMigrations (db: Db, region: string | null, kvsUrl: string): Promise<void> {
|
||||
console.log('Start calendar migrations')
|
||||
const token = generateToken(systemAccountUuid, undefined, { service: 'tool', admin: 'true' })
|
||||
@@ -124,14 +123,14 @@ async function migrateCalendarIntegrations (
|
||||
}
|
||||
// Check/create integration in account
|
||||
const existing = await accountClient.getIntegration({
|
||||
kind: CALENDAR_INTEGRATION,
|
||||
kind: calendarIntegrationKind,
|
||||
workspaceUuid: ws.uuid,
|
||||
socialId: personId
|
||||
})
|
||||
|
||||
if (existing == null) {
|
||||
await accountClient.createIntegration({
|
||||
kind: CALENDAR_INTEGRATION,
|
||||
kind: calendarIntegrationKind,
|
||||
workspaceUuid: ws.uuid,
|
||||
socialId: personId
|
||||
})
|
||||
@@ -139,7 +138,7 @@ async function migrateCalendarIntegrations (
|
||||
|
||||
const existingToken = await accountClient.getIntegrationSecret({
|
||||
key: token.email,
|
||||
kind: CALENDAR_INTEGRATION,
|
||||
kind: calendarIntegrationKind,
|
||||
socialId: personId,
|
||||
workspaceUuid: ws.uuid
|
||||
})
|
||||
@@ -152,7 +151,7 @@ async function migrateCalendarIntegrations (
|
||||
if (existingToken == null) {
|
||||
await accountClient.addIntegrationSecret({
|
||||
key: newToken.email,
|
||||
kind: CALENDAR_INTEGRATION,
|
||||
kind: calendarIntegrationKind,
|
||||
socialId: personId,
|
||||
secret: JSON.stringify(newToken),
|
||||
workspaceUuid: newToken.workspace
|
||||
@@ -164,7 +163,7 @@ async function migrateCalendarIntegrations (
|
||||
}
|
||||
await accountClient.updateIntegrationSecret({
|
||||
key: newToken.email,
|
||||
kind: CALENDAR_INTEGRATION,
|
||||
kind: calendarIntegrationKind,
|
||||
socialId: personId,
|
||||
secret: JSON.stringify(updatedToken),
|
||||
workspaceUuid: newToken.workspace
|
||||
@@ -194,7 +193,7 @@ async function migrateCalendarHistory (
|
||||
const calendarHistory = db.collection<OldHistory>('calendarHistories')
|
||||
const calendarHistories = await calendarHistory.find({}).toArray()
|
||||
|
||||
const kvsClient = getKvsClient(CALENDAR_INTEGRATION, kvsUrl, token)
|
||||
const kvsClient = getKvsClient(calendarIntegrationKind, kvsUrl, token)
|
||||
|
||||
for (const history of [...calendarHistories, ...allHistories]) {
|
||||
try {
|
||||
@@ -212,8 +211,8 @@ async function migrateCalendarHistory (
|
||||
|
||||
const key =
|
||||
history.calendarId != null
|
||||
? `${CALENDAR_INTEGRATION}:eventHistory:${ws.uuid}:${personId}:${history.email}:${history.calendarId}`
|
||||
: `${CALENDAR_INTEGRATION}:calendarsHistory:${ws.uuid}:${personId}:${history.email}`
|
||||
? `${calendarIntegrationKind}:eventHistory:${ws.uuid}:${personId}:${history.email}:${history.calendarId}`
|
||||
: `${calendarIntegrationKind}:calendarsHistory:${ws.uuid}:${personId}:${history.email}`
|
||||
|
||||
await kvsClient.setValue(key, history.historyId)
|
||||
} catch (e) {
|
||||
@@ -229,7 +228,7 @@ async function migrateCalendarHistory (
|
||||
continue
|
||||
}
|
||||
|
||||
const key = `${CALENDAR_INTEGRATION}:calendarSync:${ws.uuid}`
|
||||
const key = `${calendarIntegrationKind}:calendarSync:${ws.uuid}`
|
||||
await kvsClient.setValue(key, history.timestamp)
|
||||
}
|
||||
console.log('Finished migrating gmail history, count:', allHistories.length)
|
||||
|
||||
@@ -11,7 +11,8 @@ import core, {
|
||||
type Ref,
|
||||
type Tx,
|
||||
type TxCUD,
|
||||
type WorkspaceUuid
|
||||
type WorkspaceUuid,
|
||||
type IntegrationKind
|
||||
} from '@hcengineering/core'
|
||||
import { getAccountsFromTxes, getSocialKeyByOldEmail } from '@hcengineering/model-core'
|
||||
import { getAccountClient } from '@hcengineering/server-client'
|
||||
@@ -149,13 +150,15 @@ export async function performGithubAccountMigrations (
|
||||
}
|
||||
|
||||
const sid = socialKeyByAccount[it.accountId]
|
||||
const kind: IntegrationKind = 'github' as any
|
||||
const userKind: IntegrationKind = 'github-user' as any
|
||||
|
||||
const person = sid !== undefined ? await accountClient.findSocialIdBySocialKey(sid) : undefined
|
||||
if (person !== undefined) {
|
||||
// Check/create integeration in account
|
||||
|
||||
const existing = await githubAccountClient.getIntegration({
|
||||
kind: 'github',
|
||||
kind,
|
||||
workspaceUuid: ws?.uuid,
|
||||
socialId: person
|
||||
})
|
||||
@@ -163,7 +166,7 @@ export async function performGithubAccountMigrations (
|
||||
if (existing == null) {
|
||||
try {
|
||||
await githubAccountClient.createIntegration({
|
||||
kind: 'github',
|
||||
kind,
|
||||
workspaceUuid: ws?.uuid,
|
||||
socialId: person,
|
||||
data: {
|
||||
@@ -191,7 +194,7 @@ export async function performGithubAccountMigrations (
|
||||
const { _id, accounts, ...data } = u
|
||||
|
||||
const existing = await githubAccountClient.getIntegration({
|
||||
kind: 'github-user',
|
||||
kind: userKind,
|
||||
workspaceUuid: null,
|
||||
socialId: person
|
||||
})
|
||||
@@ -199,7 +202,7 @@ export async function performGithubAccountMigrations (
|
||||
if (existing == null) {
|
||||
try {
|
||||
await githubAccountClient.createIntegration({
|
||||
kind: 'github-user',
|
||||
kind: userKind,
|
||||
workspaceUuid: null,
|
||||
socialId: person,
|
||||
data: {
|
||||
@@ -208,7 +211,7 @@ export async function performGithubAccountMigrations (
|
||||
})
|
||||
// Check/create integeration in account
|
||||
await githubAccountClient.addIntegrationSecret({
|
||||
kind: 'github-user',
|
||||
kind: userKind,
|
||||
workspaceUuid: null,
|
||||
socialId: person,
|
||||
key: u._id, // github login
|
||||
|
||||
@@ -2,6 +2,7 @@ import core, {
|
||||
type AccountUuid,
|
||||
DOMAIN_MODEL_TX,
|
||||
type Doc,
|
||||
type IntegrationKind,
|
||||
MeasureMetricsContext,
|
||||
type PersonId,
|
||||
type Ref,
|
||||
@@ -67,7 +68,7 @@ interface WorkspaceInfoProvider {
|
||||
getWorkspaceInfo: (workspaceUuid: WorkspaceUuid) => Promise<WorkspaceInfoWithStatus | undefined>
|
||||
}
|
||||
|
||||
const GMAIL_INTEGRATION = 'gmail'
|
||||
const GMAIL_INTEGRATION: IntegrationKind = 'gmail' as any
|
||||
const TOKEN_TYPE = 'token'
|
||||
|
||||
export async function performGmailAccountMigrations (
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
type BitrixEntityMapping,
|
||||
type BitrixFieldMapping,
|
||||
type BitrixSyncDoc,
|
||||
type Fields
|
||||
type Fields,
|
||||
bitrixIntegrationKind
|
||||
} from '@hcengineering/bitrix'
|
||||
import {
|
||||
type AnyAttribute,
|
||||
@@ -112,7 +113,8 @@ export function createModel (builder: Builder): void {
|
||||
icon: bitrix.component.BitrixIcon,
|
||||
allowMultiple: false,
|
||||
createComponent: bitrix.component.BitrixConnect,
|
||||
configureComponent: bitrix.component.BitrixConfigure
|
||||
configureComponent: bitrix.component.BitrixConfigure,
|
||||
kind: bitrixIntegrationKind
|
||||
},
|
||||
bitrix.integrationType.Bitrix
|
||||
)
|
||||
|
||||
@@ -69,6 +69,7 @@ import { type AnyComponent } from '@hcengineering/ui/src/types'
|
||||
import workbench from '@hcengineering/model-workbench'
|
||||
import { WidgetType } from '@hcengineering/workbench'
|
||||
import preference, { TPreference } from '@hcengineering/model-preference'
|
||||
import { calendarIntegrationKind } from '@hcengineering/calendar'
|
||||
|
||||
import calendar from './plugin'
|
||||
|
||||
@@ -248,8 +249,11 @@ export function createModel (builder: Builder): void {
|
||||
allowMultiple: true,
|
||||
createComponent: calendar.component.IntegrationConnect,
|
||||
onDisconnect: calendar.handler.DisconnectHandler,
|
||||
onDisconnectAll: calendar.handler.DisconnectAllHandler,
|
||||
reconnectComponent: calendar.component.IntegrationConnect,
|
||||
configureComponent: calendar.component.IntegrationConfigure
|
||||
configureComponent: calendar.component.IntegrationConfigure,
|
||||
stateComponent: calendar.component.IntegrationState,
|
||||
kind: calendarIntegrationKind
|
||||
},
|
||||
calendar.integrationType.Calendar
|
||||
)
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
import activity from '@hcengineering/activity'
|
||||
import { type Channel } from '@hcengineering/contact'
|
||||
import { type Class, type Domain, IndexKind, type Ref, type Timestamp, type Type } from '@hcengineering/core'
|
||||
import type { Message, NewMessage, SharedMessage, SharedMessages } from '@hcengineering/gmail'
|
||||
import {
|
||||
type Message,
|
||||
type NewMessage,
|
||||
type SharedMessage,
|
||||
type SharedMessages,
|
||||
gmailIntegrationKind
|
||||
} from '@hcengineering/gmail'
|
||||
import {
|
||||
ArrOf,
|
||||
type Builder,
|
||||
@@ -154,11 +160,14 @@ export function createModel (builder: Builder): void {
|
||||
label: gmail.string.IntegrationLabel,
|
||||
description: gmail.string.IntegrationDescription,
|
||||
icon: gmail.component.IconGmail,
|
||||
allowMultiple: false, // enable in future
|
||||
allowMultiple: true,
|
||||
createComponent: gmail.component.Connect,
|
||||
onDisconnect: gmail.handler.DisconnectHandler,
|
||||
onDisconnectAll: gmail.handler.DisconnectAllHandler,
|
||||
reconnectComponent: gmail.component.Connect,
|
||||
configureComponent: gmail.component.Configure
|
||||
configureComponent: gmail.component.Configure,
|
||||
stateComponent: gmail.component.IntegrationState,
|
||||
kind: gmailIntegrationKind
|
||||
},
|
||||
gmail.integrationType.Gmail
|
||||
)
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
type AccountUuid,
|
||||
type Blob,
|
||||
type ClassCollaborators,
|
||||
type Ref
|
||||
type Ref,
|
||||
type IntegrationKind
|
||||
} from '@hcengineering/core'
|
||||
import exportPlugin from '@hcengineering/export'
|
||||
import { Mixin, Model, UX, type Builder } from '@hcengineering/model'
|
||||
@@ -92,6 +93,8 @@ export class TIntegrationType extends TDoc implements IntegrationType {
|
||||
reconnectComponent?: AnyComponent
|
||||
onDisconnect!: Handler
|
||||
configureComponent?: AnyComponent
|
||||
kind!: IntegrationKind
|
||||
stateComponent?: AnyComponent
|
||||
}
|
||||
|
||||
@Mixin(setting.mixin.Editable, core.class.Class)
|
||||
@@ -157,10 +160,6 @@ export function createModel (builder: Builder): void {
|
||||
fields: ['modifiedBy']
|
||||
})
|
||||
|
||||
builder.mixin(setting.class.Integration, core.class.Class, view.mixin.ObjectPanel, {
|
||||
component: setting.component.IntegrationPanel
|
||||
})
|
||||
|
||||
builder.createDoc(
|
||||
setting.class.SettingsCategory,
|
||||
core.space.Model,
|
||||
|
||||
@@ -46,7 +46,6 @@ export default mergeIds(settingId, setting, {
|
||||
CreateMixin: '' as AnyComponent,
|
||||
InviteSetting: '' as AnyComponent,
|
||||
ArrayEditor: '' as AnyComponent,
|
||||
IntegrationPanel: '' as AnyComponent,
|
||||
Configure: '' as AnyComponent,
|
||||
SettingsWidget: '' as AnyComponent
|
||||
},
|
||||
|
||||
@@ -33,11 +33,12 @@ import attachment from '@hcengineering/model-attachment'
|
||||
import contact from '@hcengineering/model-contact'
|
||||
import core, { TAttachedDoc } from '@hcengineering/model-core'
|
||||
import setting from '@hcengineering/setting'
|
||||
import type {
|
||||
NewTelegramMessage,
|
||||
SharedTelegramMessage,
|
||||
SharedTelegramMessages,
|
||||
TelegramMessage
|
||||
import {
|
||||
type NewTelegramMessage,
|
||||
type SharedTelegramMessage,
|
||||
type SharedTelegramMessages,
|
||||
type TelegramMessage,
|
||||
telegramIntegrationKind
|
||||
} from '@hcengineering/telegram'
|
||||
import templates from '@hcengineering/templates'
|
||||
import view from '@hcengineering/view'
|
||||
@@ -158,10 +159,14 @@ export function createModel (builder: Builder): void {
|
||||
description: telegram.string.TelegramIntegrationDesc,
|
||||
descriptionComponent: telegram.component.TelegramIntegrationDescription,
|
||||
icon: telegram.component.IconTelegram,
|
||||
allowMultiple: false,
|
||||
allowMultiple: true,
|
||||
createComponent: telegram.component.Connect,
|
||||
reconnectComponent: telegram.component.Reconnect,
|
||||
onDisconnect: telegram.handler.DisconnectHandler
|
||||
configureComponent: telegram.component.Configure,
|
||||
stateComponent: telegram.component.StateComponent,
|
||||
onDisconnect: telegram.handler.DisconnectHandler,
|
||||
onDisconnectAll: telegram.handler.DisconnectAllHandler,
|
||||
kind: telegramIntegrationKind
|
||||
},
|
||||
telegram.integrationType.Telegram
|
||||
)
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
type WorkspaceMode,
|
||||
Person,
|
||||
WorkspaceInfo,
|
||||
AccountInfo
|
||||
AccountInfo,
|
||||
IntegrationKind
|
||||
} from '@hcengineering/core'
|
||||
|
||||
export interface LoginInfo {
|
||||
@@ -92,9 +93,10 @@ export interface MailboxInfo {
|
||||
|
||||
export interface Integration {
|
||||
socialId: PersonId
|
||||
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||
kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||
workspaceUuid: WorkspaceUuid | null
|
||||
data?: Record<string, any>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SocialId extends SocialIdBase {
|
||||
@@ -106,7 +108,7 @@ export type IntegrationKey = Omit<Integration, 'data'>
|
||||
|
||||
export interface IntegrationSecret {
|
||||
socialId: PersonId
|
||||
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||
kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
|
||||
workspaceUuid: WorkspaceUuid | null
|
||||
key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc.
|
||||
secret: string
|
||||
|
||||
@@ -903,3 +903,8 @@ export interface ClassCollaborators<T extends Doc> extends Doc {
|
||||
export interface Collaborator extends AttachedDoc {
|
||||
collaborator: AccountUuid
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type IntegrationKind = string & { __IntegrationKind: true }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Set up fetch mock
|
||||
require('jest-fetch-mock').enableMocks()
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@hcengineering/integration-client",
|
||||
"version": "0.6.0",
|
||||
"main": "lib/index.js",
|
||||
"svelte": "src/index.ts",
|
||||
"types": "types/index.d.ts",
|
||||
"files": [
|
||||
"lib/**/*",
|
||||
"types/**/*",
|
||||
"tsconfig.json"
|
||||
],
|
||||
"author": "Hardcore Engineering Inc.",
|
||||
"license": "EPL-2.0",
|
||||
"scripts": {
|
||||
"build": "compile",
|
||||
"build:watch": "compile",
|
||||
"format": "format src",
|
||||
"test": "jest --passWithNoTests --silent",
|
||||
"_phase:build": "compile transpile src",
|
||||
"_phase:test": "jest --passWithNoTests --silent",
|
||||
"_phase:format": "format src",
|
||||
"_phase:validate": "compile validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "~7.0.3",
|
||||
"@hcengineering/platform-rig": "^0.6.0",
|
||||
"@types/node": "^22.15.29",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-n": "^15.4.0",
|
||||
"eslint": "^8.54.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"eslint-config-standard-with-typescript": "^40.0.0",
|
||||
"prettier": "^3.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/uuid": "^8.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcengineering/account-client": "^0.6.0",
|
||||
"@hcengineering/core": "^0.6.32",
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/kvs-client": "^0.6.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"repository": "https://github.com/hcengineering/platform",
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./types/index.d.ts",
|
||||
"require": "./lib/index.js",
|
||||
"import": "./lib/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core'
|
||||
import { Integration, IntegrationKey, type AccountClient } from '@hcengineering/account-client'
|
||||
import { IntegrationClientImpl } from '../client'
|
||||
import { IntegrationClient } from '../types'
|
||||
|
||||
// Mock the events module before importing anything else
|
||||
const mockEvents = {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
emit: jest.fn()
|
||||
}
|
||||
|
||||
jest.mock('../events', () => ({
|
||||
getIntegrationEventBus: () => mockEvents
|
||||
}))
|
||||
|
||||
// Mock account client
|
||||
const mockAccountClient: jest.Mocked<AccountClient> = {
|
||||
listIntegrations: jest.fn(),
|
||||
getIntegration: jest.fn(),
|
||||
createIntegration: jest.fn(),
|
||||
updateIntegration: jest.fn(),
|
||||
deleteIntegration: jest.fn()
|
||||
} as any
|
||||
|
||||
const integrationKind: IntegrationKind = 'gmail' as IntegrationKind
|
||||
const serviceName = 'gmail-service'
|
||||
const testSocialId: PersonId = 'social-123' as PersonId
|
||||
const testWorkspaceId: WorkspaceUuid = 'workspace-456' as WorkspaceUuid
|
||||
|
||||
describe('IntegrationClientImpl', () => {
|
||||
let client: IntegrationClient
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Reset mock event handlers
|
||||
mockEvents.on.mockReturnValue(() => {})
|
||||
mockEvents.off.mockImplementation(() => {})
|
||||
mockEvents.emit.mockImplementation(() => {})
|
||||
|
||||
client = new IntegrationClientImpl(mockAccountClient, integrationKind, serviceName)
|
||||
})
|
||||
|
||||
describe('getIntegrations', () => {
|
||||
it('should return integrations from account client', async () => {
|
||||
const mockIntegrations: Integration[] = [
|
||||
{
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: { test: true }
|
||||
}
|
||||
]
|
||||
|
||||
mockAccountClient.listIntegrations.mockResolvedValue(mockIntegrations)
|
||||
|
||||
const result = await client.getIntegrations()
|
||||
|
||||
expect(mockAccountClient.listIntegrations).toHaveBeenCalledWith({ kind: integrationKind })
|
||||
expect(result).toEqual(mockIntegrations)
|
||||
})
|
||||
|
||||
it('should return empty array when account client returns null', async () => {
|
||||
mockAccountClient.listIntegrations.mockResolvedValue([])
|
||||
|
||||
const result = await client.getIntegrations()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConnection', () => {
|
||||
it('should return integration if it is already a connection', async () => {
|
||||
const connectionIntegration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: { isConnection: true }
|
||||
}
|
||||
|
||||
const result = await client.getConnection(connectionIntegration)
|
||||
|
||||
expect(result).toBe(connectionIntegration)
|
||||
expect(mockAccountClient.getIntegration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fetch connection for non-connection integration', async () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: { test: true }
|
||||
}
|
||||
|
||||
const connectionIntegration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: { isConnection: true }
|
||||
}
|
||||
|
||||
mockAccountClient.getIntegration.mockResolvedValue(connectionIntegration)
|
||||
|
||||
const result = await client.getConnection(integration)
|
||||
|
||||
expect(mockAccountClient.getIntegration).toHaveBeenCalledWith({
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null
|
||||
})
|
||||
expect(result).toBe(connectionIntegration)
|
||||
})
|
||||
|
||||
it('should emit error event and rethrow when getIntegration fails', async () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: { test: true }
|
||||
}
|
||||
|
||||
const error = new Error('Connection failed')
|
||||
mockAccountClient.getIntegration.mockRejectedValue(error)
|
||||
|
||||
await expect(client.getConnection(integration)).rejects.toThrow('Connection failed')
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'integration:error',
|
||||
expect.objectContaining({
|
||||
operation: 'connect',
|
||||
error: 'Connection failed',
|
||||
socialId: testSocialId,
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integrate', () => {
|
||||
it('should create new integration when none exists', async () => {
|
||||
const connection: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: { _id: 'conn-123', other: 'data' }
|
||||
}
|
||||
|
||||
mockAccountClient.getIntegration.mockResolvedValue(null)
|
||||
mockAccountClient.createIntegration.mockResolvedValue(undefined)
|
||||
|
||||
const result = await client.integrate(connection, testWorkspaceId)
|
||||
|
||||
expect(mockAccountClient.createIntegration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: expect.objectContaining({
|
||||
connectionId: 'conn-123',
|
||||
_id: expect.any(String),
|
||||
other: 'data'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'integration:created',
|
||||
expect.objectContaining({
|
||||
integration: expect.any(Object),
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
socialId: testSocialId,
|
||||
workspaceUuid: testWorkspaceId
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return existing integration when it already exists', async () => {
|
||||
const connection: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: { _id: 'conn-123' }
|
||||
}
|
||||
|
||||
const existingIntegration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: { existing: true }
|
||||
}
|
||||
|
||||
mockAccountClient.getIntegration.mockResolvedValue(existingIntegration)
|
||||
|
||||
const result = await client.integrate(connection, testWorkspaceId)
|
||||
|
||||
expect(mockAccountClient.createIntegration).not.toHaveBeenCalled()
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'integration:updated',
|
||||
expect.objectContaining({
|
||||
integration: expect.any(Object),
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
expect(result).toBe(existingIntegration)
|
||||
})
|
||||
|
||||
it('should emit error event when integration fails', async () => {
|
||||
const connection: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: {}
|
||||
}
|
||||
|
||||
const error = new Error('Integration failed')
|
||||
mockAccountClient.getIntegration.mockRejectedValue(error)
|
||||
|
||||
await expect(client.integrate(connection, testWorkspaceId)).rejects.toThrow('Integration failed')
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'integration:error',
|
||||
expect.objectContaining({
|
||||
operation: 'integrate',
|
||||
error: 'Integration failed',
|
||||
workspaceUuid: testWorkspaceId,
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('connect', () => {
|
||||
it('should create new connection when none exists', async () => {
|
||||
mockAccountClient.getIntegration.mockResolvedValue(null)
|
||||
mockAccountClient.createIntegration.mockResolvedValue(undefined)
|
||||
|
||||
const data = { test: 'data' }
|
||||
const result = await client.connect(testSocialId, data)
|
||||
|
||||
expect(mockAccountClient.createIntegration).toHaveBeenCalledWith({
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data
|
||||
})
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'connection:created',
|
||||
expect.objectContaining({
|
||||
integration: expect.objectContaining({
|
||||
socialId: testSocialId,
|
||||
workspaceUuid: null
|
||||
}),
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
socialId: testSocialId,
|
||||
workspaceUuid: null,
|
||||
data
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return existing connection when it already exists', async () => {
|
||||
const existingConnection: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: { existing: true }
|
||||
}
|
||||
|
||||
mockAccountClient.getIntegration.mockResolvedValue(existingConnection)
|
||||
|
||||
const result = await client.connect(testSocialId)
|
||||
|
||||
expect(mockAccountClient.createIntegration).not.toHaveBeenCalled()
|
||||
expect(mockAccountClient.updateIntegration).not.toHaveBeenCalled()
|
||||
expect(result).toBe(existingConnection)
|
||||
})
|
||||
|
||||
it('should update existing connection when data is different', async () => {
|
||||
const existingConnection: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: { existing: true, version: 1 }
|
||||
}
|
||||
|
||||
const newData = { existing: true, version: 2, newField: 'value' }
|
||||
const updatedConnection = {
|
||||
...existingConnection,
|
||||
data: newData
|
||||
}
|
||||
|
||||
mockAccountClient.getIntegration.mockResolvedValue(existingConnection)
|
||||
mockAccountClient.updateIntegration.mockResolvedValue(undefined)
|
||||
|
||||
const result = await client.connect(testSocialId, newData)
|
||||
|
||||
expect(mockAccountClient.createIntegration).not.toHaveBeenCalled()
|
||||
expect(mockAccountClient.updateIntegration).toHaveBeenCalledWith(updatedConnection)
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'connection:updated',
|
||||
expect.objectContaining({
|
||||
integration: expect.objectContaining({
|
||||
data: newData
|
||||
}),
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual(updatedConnection)
|
||||
})
|
||||
|
||||
it('should not update existing connection when data is the same', async () => {
|
||||
const existingData = { existing: true, version: 1 }
|
||||
const existingConnection: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: existingData
|
||||
}
|
||||
|
||||
mockAccountClient.getIntegration.mockResolvedValue(existingConnection)
|
||||
|
||||
const result = await client.connect(testSocialId, existingData)
|
||||
|
||||
expect(mockAccountClient.createIntegration).not.toHaveBeenCalled()
|
||||
expect(mockAccountClient.updateIntegration).not.toHaveBeenCalled()
|
||||
expect(mockEvents.emit).not.toHaveBeenCalledWith('connection:updated', expect.any(Object))
|
||||
expect(result).toBe(existingConnection)
|
||||
})
|
||||
|
||||
it('should emit error event when connect fails', async () => {
|
||||
const error = new Error('Connect failed')
|
||||
mockAccountClient.getIntegration.mockRejectedValue(error)
|
||||
|
||||
await expect(client.connect(testSocialId)).rejects.toThrow('Connect failed')
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'integration:error',
|
||||
expect.objectContaining({
|
||||
operation: 'connect',
|
||||
error: 'Connect failed',
|
||||
socialId: testSocialId,
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateConfig', () => {
|
||||
it('should update integration config', async () => {
|
||||
const integrationKey: IntegrationKey = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId
|
||||
}
|
||||
|
||||
const existingIntegration: Integration = {
|
||||
...integrationKey,
|
||||
data: { config: { old: true }, other: 'data' }
|
||||
}
|
||||
|
||||
const newConfig = { new: true }
|
||||
const refreshFn = jest.fn().mockResolvedValue(undefined)
|
||||
|
||||
mockAccountClient.getIntegration.mockResolvedValue(existingIntegration)
|
||||
mockAccountClient.updateIntegration.mockResolvedValue(undefined)
|
||||
|
||||
await client.updateConfig(integrationKey, newConfig, refreshFn)
|
||||
|
||||
expect(mockAccountClient.updateIntegration).toHaveBeenCalledWith({
|
||||
...existingIntegration,
|
||||
data: {
|
||||
config: newConfig,
|
||||
other: 'data'
|
||||
}
|
||||
})
|
||||
|
||||
expect(refreshFn).toHaveBeenCalled()
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'integration:updated',
|
||||
expect.objectContaining({
|
||||
integration: existingIntegration,
|
||||
oldConfig: { old: true },
|
||||
newConfig,
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when integration not found', async () => {
|
||||
const integrationKey: IntegrationKey = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId
|
||||
}
|
||||
|
||||
mockAccountClient.getIntegration.mockResolvedValue(null)
|
||||
|
||||
await expect(client.updateConfig(integrationKey, {})).rejects.toThrow('Integration not found')
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'integration:error',
|
||||
expect.objectContaining({
|
||||
operation: 'updateConfig',
|
||||
integrationKey,
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeIntegration', () => {
|
||||
it('should return undefined when socialId is null', async () => {
|
||||
const result = await client.removeIntegration(null, testWorkspaceId)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove integration and keep connection', async () => {
|
||||
const integrations: Integration[] = [
|
||||
{
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
]
|
||||
|
||||
const remainingIntegrations: Integration[] = [
|
||||
{
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null, // connection
|
||||
data: {}
|
||||
},
|
||||
{
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: 'other-workspace' as WorkspaceUuid,
|
||||
data: {}
|
||||
}
|
||||
]
|
||||
|
||||
mockAccountClient.listIntegrations
|
||||
.mockResolvedValueOnce(integrations)
|
||||
.mockResolvedValueOnce(remainingIntegrations)
|
||||
|
||||
const result = await client.removeIntegration(testSocialId, testWorkspaceId)
|
||||
|
||||
expect(mockAccountClient.deleteIntegration).toHaveBeenCalledWith({
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId
|
||||
})
|
||||
|
||||
expect(result).toEqual({ connectionRemoved: false })
|
||||
})
|
||||
|
||||
it('should remove integration and connection when it was the last one', async () => {
|
||||
const integrations: Integration[] = [
|
||||
{
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
]
|
||||
|
||||
const remainingIntegrations: Integration[] = [
|
||||
{
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null, // connection only
|
||||
data: {}
|
||||
}
|
||||
]
|
||||
|
||||
mockAccountClient.listIntegrations
|
||||
.mockResolvedValueOnce(integrations)
|
||||
.mockResolvedValueOnce(remainingIntegrations)
|
||||
.mockResolvedValueOnce([]) // after removing connection
|
||||
|
||||
const result = await client.removeIntegration(testSocialId, testWorkspaceId)
|
||||
|
||||
expect(result).toEqual({ connectionRemoved: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeConnection', () => {
|
||||
it('should remove all integrations for the connection', async () => {
|
||||
const connection: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: {}
|
||||
}
|
||||
|
||||
const integrations: Integration[] = [
|
||||
connection,
|
||||
{
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
]
|
||||
|
||||
mockAccountClient.listIntegrations.mockResolvedValue(integrations)
|
||||
|
||||
await client.removeConnection(connection)
|
||||
|
||||
expect(mockAccountClient.deleteIntegration).toHaveBeenCalledTimes(2)
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'integration:deleted',
|
||||
expect.objectContaining({
|
||||
integration: expect.any(Object),
|
||||
timestamp: expect.any(Number)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { IntegrationEventEmitter, getIntegrationEventBus, onIntegrationEvent } from '../events'
|
||||
|
||||
describe('IntegrationEventEmitter', () => {
|
||||
let emitter: IntegrationEventEmitter
|
||||
|
||||
beforeEach(() => {
|
||||
emitter = new IntegrationEventEmitter()
|
||||
})
|
||||
|
||||
describe('on', () => {
|
||||
it('should register event listener and return unsubscribe function', () => {
|
||||
const callback = jest.fn()
|
||||
const unsubscribe = emitter.on('test-event', callback)
|
||||
|
||||
expect(typeof unsubscribe).toBe('function')
|
||||
expect(emitter.listenerCount('test-event')).toBe(1)
|
||||
})
|
||||
|
||||
it('should support multiple listeners for the same event', () => {
|
||||
const callback1 = jest.fn()
|
||||
const callback2 = jest.fn()
|
||||
|
||||
emitter.on('test-event', callback1)
|
||||
emitter.on('test-event', callback2)
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('emit', () => {
|
||||
it('should call all registered listeners with event data', () => {
|
||||
const callback1 = jest.fn()
|
||||
const callback2 = jest.fn()
|
||||
const testData = { test: 'data' }
|
||||
|
||||
emitter.on('test-event', callback1)
|
||||
emitter.on('test-event', callback2)
|
||||
|
||||
emitter.emit('test-event', testData)
|
||||
|
||||
expect(callback1).toHaveBeenCalledWith(testData)
|
||||
expect(callback2).toHaveBeenCalledWith(testData)
|
||||
})
|
||||
|
||||
it('should not throw when emitting event with no listeners', () => {
|
||||
expect(() => {
|
||||
emitter.emit('non-existent-event', {})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should continue calling other listeners if one throws an error', () => {
|
||||
const callback1 = jest.fn(() => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
const callback2 = jest.fn()
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
emitter.on('test-event', callback1)
|
||||
emitter.on('test-event', callback2)
|
||||
|
||||
emitter.emit('test-event', {})
|
||||
|
||||
expect(callback1).toHaveBeenCalled()
|
||||
expect(callback2).toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error in event listener for 'test-event':", expect.any(Error))
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('off', () => {
|
||||
it('should remove specific listener', () => {
|
||||
const callback1 = jest.fn()
|
||||
const callback2 = jest.fn()
|
||||
|
||||
emitter.on('test-event', callback1)
|
||||
emitter.on('test-event', callback2)
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(2)
|
||||
|
||||
emitter.off('test-event', callback1)
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(1)
|
||||
|
||||
emitter.emit('test-event', {})
|
||||
expect(callback1).not.toHaveBeenCalled()
|
||||
expect(callback2).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove all listeners for event when no callback specified', () => {
|
||||
const callback1 = jest.fn()
|
||||
const callback2 = jest.fn()
|
||||
|
||||
emitter.on('test-event', callback1)
|
||||
emitter.on('test-event', callback2)
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(2)
|
||||
|
||||
emitter.off('test-event')
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(0)
|
||||
})
|
||||
|
||||
it('should not throw when removing non-existent listener', () => {
|
||||
const callback = jest.fn()
|
||||
expect(() => {
|
||||
emitter.off('non-existent-event', callback)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should clean up event when last listener is removed', () => {
|
||||
const callback = jest.fn()
|
||||
emitter.on('test-event', callback)
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(1)
|
||||
|
||||
emitter.off('test-event', callback)
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsubscribe function', () => {
|
||||
it('should remove listener when called', () => {
|
||||
const callback = jest.fn()
|
||||
const unsubscribe = emitter.on('test-event', callback)
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(1)
|
||||
|
||||
unsubscribe()
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(0)
|
||||
})
|
||||
|
||||
it('should clean up event when last listener unsubscribes', () => {
|
||||
const callback1 = jest.fn()
|
||||
const callback2 = jest.fn()
|
||||
|
||||
const unsubscribe1 = emitter.on('test-event', callback1)
|
||||
const unsubscribe2 = emitter.on('test-event', callback2)
|
||||
|
||||
expect(emitter.listenerCount('test-event')).toBe(2)
|
||||
|
||||
unsubscribe1()
|
||||
expect(emitter.listenerCount('test-event')).toBe(1)
|
||||
|
||||
unsubscribe2()
|
||||
expect(emitter.listenerCount('test-event')).toBe(0)
|
||||
})
|
||||
|
||||
it('should be safe to call multiple times', () => {
|
||||
const callback = jest.fn()
|
||||
const unsubscribe = emitter.on('test-event', callback)
|
||||
|
||||
unsubscribe()
|
||||
expect(() => {
|
||||
unsubscribe()
|
||||
}).not.toThrow()
|
||||
expect(emitter.listenerCount('test-event')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAllListeners', () => {
|
||||
it('should remove all listeners for specific event', () => {
|
||||
emitter.on('event1', jest.fn())
|
||||
emitter.on('event1', jest.fn())
|
||||
emitter.on('event2', jest.fn())
|
||||
|
||||
expect(emitter.listenerCount('event1')).toBe(2)
|
||||
expect(emitter.listenerCount('event2')).toBe(1)
|
||||
|
||||
emitter.removeAllListeners('event1')
|
||||
|
||||
expect(emitter.listenerCount('event1')).toBe(0)
|
||||
expect(emitter.listenerCount('event2')).toBe(1)
|
||||
})
|
||||
|
||||
it('should remove all listeners for all events when no event specified', () => {
|
||||
emitter.on('event1', jest.fn())
|
||||
emitter.on('event2', jest.fn())
|
||||
|
||||
expect(emitter.listenerCount('event1')).toBe(1)
|
||||
expect(emitter.listenerCount('event2')).toBe(1)
|
||||
|
||||
emitter.removeAllListeners()
|
||||
|
||||
expect(emitter.listenerCount('event1')).toBe(0)
|
||||
expect(emitter.listenerCount('event2')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listenerCount', () => {
|
||||
it('should return correct listener count', () => {
|
||||
expect(emitter.listenerCount('test-event')).toBe(0)
|
||||
|
||||
emitter.on('test-event', jest.fn())
|
||||
expect(emitter.listenerCount('test-event')).toBe(1)
|
||||
|
||||
emitter.on('test-event', jest.fn())
|
||||
expect(emitter.listenerCount('test-event')).toBe(2)
|
||||
})
|
||||
|
||||
it('should return 0 for non-existent events', () => {
|
||||
expect(emitter.listenerCount('non-existent')).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getIntegrationEventBus', () => {
|
||||
afterEach(() => {
|
||||
// Reset the global instance after each test
|
||||
const eventBus = getIntegrationEventBus()
|
||||
eventBus.removeAllListeners()
|
||||
})
|
||||
|
||||
it('should return singleton instance', () => {
|
||||
const eventBus1 = getIntegrationEventBus()
|
||||
const eventBus2 = getIntegrationEventBus()
|
||||
|
||||
expect(eventBus1).toBe(eventBus2)
|
||||
expect(eventBus1).toBeInstanceOf(IntegrationEventEmitter)
|
||||
})
|
||||
|
||||
it('should maintain state across calls', () => {
|
||||
const eventBus1 = getIntegrationEventBus()
|
||||
eventBus1.on('test-event', jest.fn())
|
||||
|
||||
const eventBus2 = getIntegrationEventBus()
|
||||
expect(eventBus2.listenerCount('test-event')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onIntegrationEvent', () => {
|
||||
let mockCallback: jest.Mock
|
||||
let mockFilter: jest.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
mockCallback = jest.fn()
|
||||
mockFilter = jest.fn()
|
||||
// Clear any existing listeners
|
||||
const eventBus = getIntegrationEventBus()
|
||||
eventBus.removeAllListeners()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const eventBus = getIntegrationEventBus()
|
||||
eventBus.removeAllListeners()
|
||||
})
|
||||
|
||||
it('should register event listener without filter', () => {
|
||||
const unsubscribe = onIntegrationEvent('test-event', mockCallback)
|
||||
const eventBus = getIntegrationEventBus()
|
||||
|
||||
expect(eventBus.listenerCount('test-event')).toBe(1)
|
||||
expect(typeof unsubscribe).toBe('function')
|
||||
|
||||
const testData = { test: 'data' }
|
||||
eventBus.emit('test-event', testData)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith(testData)
|
||||
})
|
||||
|
||||
it('should register event listener with filter', () => {
|
||||
mockFilter.mockReturnValue(true)
|
||||
|
||||
onIntegrationEvent('test-event', mockCallback, mockFilter)
|
||||
const eventBus = getIntegrationEventBus()
|
||||
|
||||
const testData = { test: 'data' }
|
||||
eventBus.emit('test-event', testData)
|
||||
|
||||
expect(mockFilter).toHaveBeenCalledWith(testData)
|
||||
expect(mockCallback).toHaveBeenCalledWith(testData)
|
||||
})
|
||||
|
||||
it('should not call callback when filter returns false', () => {
|
||||
mockFilter.mockReturnValue(false)
|
||||
|
||||
onIntegrationEvent('test-event', mockCallback, mockFilter)
|
||||
const eventBus = getIntegrationEventBus()
|
||||
|
||||
const testData = { test: 'data' }
|
||||
eventBus.emit('test-event', testData)
|
||||
|
||||
expect(mockFilter).toHaveBeenCalledWith(testData)
|
||||
expect(mockCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return working unsubscribe function', () => {
|
||||
const unsubscribe = onIntegrationEvent('test-event', mockCallback)
|
||||
const eventBus = getIntegrationEventBus()
|
||||
|
||||
expect(eventBus.listenerCount('test-event')).toBe(1)
|
||||
|
||||
unsubscribe()
|
||||
|
||||
expect(eventBus.listenerCount('test-event')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import * as integrationClient from '../index'
|
||||
|
||||
describe('index exports', () => {
|
||||
it('should export IntegrationClientImpl from client', () => {
|
||||
expect(integrationClient.IntegrationClientImpl).toBeDefined()
|
||||
expect(typeof integrationClient.IntegrationClientImpl).toBe('function')
|
||||
})
|
||||
|
||||
it('should export getIntegrationClient from client', () => {
|
||||
expect(integrationClient.getIntegrationClient).toBeDefined()
|
||||
expect(typeof integrationClient.getIntegrationClient).toBe('function')
|
||||
})
|
||||
|
||||
it('should export utility functions from utils', () => {
|
||||
expect(integrationClient.isWorkspaceIntegration).toBeDefined()
|
||||
expect(integrationClient.isConnection).toBeDefined()
|
||||
expect(integrationClient.isSameIntegrationEvent).toBeDefined()
|
||||
|
||||
expect(typeof integrationClient.isWorkspaceIntegration).toBe('function')
|
||||
expect(typeof integrationClient.isConnection).toBe('function')
|
||||
expect(typeof integrationClient.isSameIntegrationEvent).toBe('function')
|
||||
})
|
||||
|
||||
it('should export event-related exports from events', () => {
|
||||
expect(integrationClient.IntegrationEventEmitter).toBeDefined()
|
||||
expect(integrationClient.getIntegrationEventBus).toBeDefined()
|
||||
expect(integrationClient.onIntegrationEvent).toBeDefined()
|
||||
|
||||
expect(typeof integrationClient.IntegrationEventEmitter).toBe('function')
|
||||
expect(typeof integrationClient.getIntegrationEventBus).toBe('function')
|
||||
expect(typeof integrationClient.onIntegrationEvent).toBe('function')
|
||||
})
|
||||
|
||||
it('should not export any undefined values', () => {
|
||||
const exports = Object.keys(integrationClient)
|
||||
exports.forEach((exportName) => {
|
||||
expect((integrationClient as any)[exportName]).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Integration } from '@hcengineering/account-client'
|
||||
import { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core'
|
||||
import { isWorkspaceIntegration, isConnection, isSameIntegrationEvent } from '../utils'
|
||||
import { IntegrationEventData } from '../types'
|
||||
|
||||
const testSocialId: PersonId = 'social-123' as PersonId
|
||||
const testWorkspaceId: WorkspaceUuid = 'workspace-456' as WorkspaceUuid
|
||||
const integrationKind: IntegrationKind = 'gmail' as IntegrationKind
|
||||
|
||||
describe('utils', () => {
|
||||
describe('isWorkspaceIntegration', () => {
|
||||
it('should return true for workspace integration', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
|
||||
expect(isWorkspaceIntegration(integration)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for connection (null workspaceUuid)', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: {}
|
||||
}
|
||||
|
||||
expect(isWorkspaceIntegration(integration)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for connection (undefined workspaceUuid)', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: undefined,
|
||||
data: {}
|
||||
} as any
|
||||
|
||||
expect(isWorkspaceIntegration(integration)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isConnection', () => {
|
||||
it('should return true for connection (null workspaceUuid)', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: {}
|
||||
}
|
||||
|
||||
expect(isConnection(integration)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for connection (undefined workspaceUuid)', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: undefined,
|
||||
data: {}
|
||||
} as any
|
||||
|
||||
expect(isConnection(integration)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for workspace integration', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
|
||||
expect(isConnection(integration)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSameIntegrationEvent', () => {
|
||||
it('should return true for matching integration event', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
|
||||
const event: IntegrationEventData = {
|
||||
integration: {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: { different: 'data' }
|
||||
},
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
expect(isSameIntegrationEvent(event, integration)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for different socialId', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
|
||||
const event: IntegrationEventData = {
|
||||
integration: {
|
||||
socialId: 'different-social' as PersonId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
},
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
expect(isSameIntegrationEvent(event, integration)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for different workspaceUuid', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
|
||||
const event: IntegrationEventData = {
|
||||
integration: {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: 'different-workspace' as WorkspaceUuid,
|
||||
data: {}
|
||||
},
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
expect(isSameIntegrationEvent(event, integration)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when event integration is undefined', () => {
|
||||
const integration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
}
|
||||
|
||||
const event: IntegrationEventData = {
|
||||
integration: undefined,
|
||||
timestamp: Date.now()
|
||||
} as any
|
||||
|
||||
expect(isSameIntegrationEvent(event, integration)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle connection vs workspace integration comparison', () => {
|
||||
const connectionIntegration: Integration = {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: null,
|
||||
data: {}
|
||||
}
|
||||
|
||||
const workspaceEvent: IntegrationEventData = {
|
||||
integration: {
|
||||
socialId: testSocialId,
|
||||
kind: integrationKind,
|
||||
workspaceUuid: testWorkspaceId,
|
||||
data: {}
|
||||
},
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
expect(isSameIntegrationEvent(workspaceEvent, connectionIntegration)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,340 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// packages/integration/src/account.ts
|
||||
|
||||
import {
|
||||
getClient as getAccountClientRaw,
|
||||
Integration,
|
||||
IntegrationKey,
|
||||
IntegrationSecret,
|
||||
type AccountClient
|
||||
} from '@hcengineering/account-client'
|
||||
import { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { IntegrationClient, IntegrationEventData, IntegrationUpdatedData, IntegrationErrorData } from './types'
|
||||
import { isConnection } from './utils'
|
||||
import { type EventCallback, getIntegrationEventBus } from './events'
|
||||
|
||||
export function getIntegrationClient (
|
||||
accountsUrl: string,
|
||||
token: string,
|
||||
integrationKind: IntegrationKind,
|
||||
serviceName: string
|
||||
): IntegrationClient {
|
||||
const client = getAccountClientRaw(accountsUrl, token)
|
||||
return new IntegrationClientImpl(client, integrationKind, serviceName)
|
||||
}
|
||||
|
||||
export class IntegrationClientImpl implements IntegrationClient {
|
||||
private readonly events = getIntegrationEventBus()
|
||||
|
||||
constructor (
|
||||
private readonly client: AccountClient,
|
||||
private readonly integrationKind: IntegrationKind,
|
||||
private readonly serviceName: string
|
||||
) {}
|
||||
|
||||
// Event methods
|
||||
on<T = any>(event: string, callback: EventCallback<T>): () => void {
|
||||
return this.events.on(event, callback)
|
||||
}
|
||||
|
||||
off (event: string, callback?: EventCallback): void {
|
||||
this.events.off(event, callback)
|
||||
}
|
||||
|
||||
private emit<T = any>(event: string, data: T): void {
|
||||
this.events.emit(event, data)
|
||||
}
|
||||
|
||||
async getIntegrations (): Promise<Integration[]> {
|
||||
return (await this.client.listIntegrations({ kind: this.integrationKind })) ?? []
|
||||
}
|
||||
|
||||
async getConnection (integration: Integration): Promise<Integration | null> {
|
||||
try {
|
||||
if (isConnection(integration)) {
|
||||
return integration
|
||||
}
|
||||
const connectionKey = {
|
||||
socialId: integration.socialId,
|
||||
kind: integration.kind,
|
||||
workspaceUuid: null
|
||||
}
|
||||
return await this.client.getIntegration(connectionKey)
|
||||
} catch (error) {
|
||||
const errorData: IntegrationErrorData = {
|
||||
operation: 'connect',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
socialId: integration.socialId,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:error', errorData)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async integrate (connection: Integration, workspace: WorkspaceUuid, data?: Record<string, any>): Promise<Integration> {
|
||||
try {
|
||||
const existingData = data ?? connection.data ?? {}
|
||||
const integration = {
|
||||
socialId: connection.socialId,
|
||||
kind: connection.kind ?? this.integrationKind,
|
||||
workspaceUuid: workspace,
|
||||
data: {
|
||||
...existingData,
|
||||
_id: uuid(),
|
||||
connectionId: connection.data?._id
|
||||
}
|
||||
}
|
||||
|
||||
const existingIntegration = await this.client.getIntegration(integration)
|
||||
if (existingIntegration != null) {
|
||||
const eventData: IntegrationEventData = {
|
||||
integration,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:updated', eventData)
|
||||
return existingIntegration
|
||||
}
|
||||
|
||||
await this.client.createIntegration(integration)
|
||||
|
||||
const eventData: IntegrationEventData = {
|
||||
integration,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:created', eventData)
|
||||
|
||||
return integration
|
||||
} catch (error) {
|
||||
const errorData: IntegrationErrorData = {
|
||||
operation: 'integrate',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
workspaceUuid: workspace,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:error', errorData)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async connect (socialId: PersonId, data?: Record<string, any>): Promise<Integration> {
|
||||
try {
|
||||
const connection = {
|
||||
socialId,
|
||||
kind: this.integrationKind,
|
||||
workspaceUuid: null,
|
||||
data
|
||||
}
|
||||
const existingConnection = await this.client.getIntegration(connection)
|
||||
if (existingConnection != null) {
|
||||
// Check if data needs to be updated
|
||||
if (data == null) {
|
||||
return existingConnection
|
||||
}
|
||||
const existingData = existingConnection.data ?? {}
|
||||
const updatedData = {
|
||||
...existingData,
|
||||
...data
|
||||
}
|
||||
const needsUpdate = data != null && JSON.stringify(updatedData) !== JSON.stringify(existingData)
|
||||
|
||||
if (needsUpdate) {
|
||||
const updatedConnection = {
|
||||
...existingConnection,
|
||||
data: updatedData
|
||||
}
|
||||
|
||||
await this.client.updateIntegration(updatedConnection)
|
||||
|
||||
const eventData: IntegrationEventData = {
|
||||
integration: updatedConnection,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('connection:updated', eventData)
|
||||
|
||||
return updatedConnection
|
||||
}
|
||||
|
||||
return existingConnection
|
||||
}
|
||||
|
||||
await this.client.createIntegration(connection)
|
||||
|
||||
const eventData: IntegrationEventData = {
|
||||
integration: connection,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('connection:created', eventData)
|
||||
|
||||
return connection
|
||||
} catch (error) {
|
||||
const errorData: IntegrationErrorData = {
|
||||
operation: 'connect',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
socialId,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:error', errorData)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async updateConfig (
|
||||
integrationKey: IntegrationKey,
|
||||
config: Record<string, any>,
|
||||
refresh?: () => Promise<void>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const integration = await this.client.getIntegration(integrationKey)
|
||||
if (integration == null) {
|
||||
throw new Error(`Integration not found: ${JSON.stringify(integrationKey)}`)
|
||||
}
|
||||
|
||||
const oldConfig = integration.data?.config
|
||||
const data = {
|
||||
...integration.data,
|
||||
config
|
||||
}
|
||||
|
||||
await this.client.updateIntegration({
|
||||
...integration,
|
||||
data
|
||||
})
|
||||
if (refresh != null) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const eventData: IntegrationUpdatedData = {
|
||||
integration,
|
||||
oldConfig,
|
||||
newConfig: config,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:updated', eventData)
|
||||
} catch (error) {
|
||||
const errorData: IntegrationErrorData = {
|
||||
operation: 'updateConfig',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
integrationKey,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:error', errorData)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async removeIntegration (
|
||||
socialId: PersonId | undefined | null,
|
||||
workspaceUuid: WorkspaceUuid | null | undefined
|
||||
): Promise<
|
||||
| {
|
||||
connectionRemoved: boolean
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
if (socialId == null) return
|
||||
|
||||
try {
|
||||
const integrations = await this.client.listIntegrations({
|
||||
kind: this.integrationKind,
|
||||
socialId,
|
||||
workspaceUuid
|
||||
})
|
||||
|
||||
for (const integration of integrations) {
|
||||
await this.client.deleteIntegration({
|
||||
socialId,
|
||||
kind: this.integrationKind,
|
||||
workspaceUuid: integration.workspaceUuid
|
||||
})
|
||||
|
||||
const eventData: IntegrationEventData = {
|
||||
integration,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:deleted', eventData)
|
||||
}
|
||||
|
||||
// Remove connection if it was the last integration for this socialId
|
||||
const remainedIntegrations = await this.client.listIntegrations({
|
||||
kind: this.integrationKind,
|
||||
socialId
|
||||
})
|
||||
if (remainedIntegrations.length === 1 && isConnection(remainedIntegrations[0])) {
|
||||
await this.removeConnection(remainedIntegrations[0])
|
||||
return {
|
||||
connectionRemoved: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
connectionRemoved: false
|
||||
}
|
||||
} catch (error) {
|
||||
const errorData: IntegrationErrorData = {
|
||||
operation: 'removeIntegration',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
socialId,
|
||||
workspaceUuid,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:error', errorData)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async removeConnection (connection: Integration): Promise<void> {
|
||||
try {
|
||||
const integrations = await this.client.listIntegrations({
|
||||
kind: connection.kind,
|
||||
socialId: connection.socialId
|
||||
})
|
||||
|
||||
for (const integration of integrations) {
|
||||
await this.client.deleteIntegration({
|
||||
socialId: integration.socialId,
|
||||
kind: integration.kind,
|
||||
workspaceUuid: integration.workspaceUuid
|
||||
})
|
||||
|
||||
const eventData: IntegrationEventData = {
|
||||
integration,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:deleted', eventData)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorData: IntegrationErrorData = {
|
||||
operation: 'removeConnection',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.emit('integration:error', errorData)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async setSecret (data: IntegrationSecret): Promise<void> {
|
||||
const { secret, ...secretKey } = data
|
||||
const currentSecret = await this.client.getIntegrationSecret(secretKey)
|
||||
if (currentSecret != null) {
|
||||
await this.client.updateIntegrationSecret(data)
|
||||
} else {
|
||||
await this.client.addIntegrationSecret(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
export type EventCallback<T = any> = (data: T) => void
|
||||
|
||||
export interface EventEmitter {
|
||||
on: <T = any>(event: string, callback: EventCallback<T>) => () => void
|
||||
emit: <T = any>(event: string, data: T) => void
|
||||
off: (event: string, callback?: EventCallback) => void
|
||||
removeAllListeners: (event?: string) => void
|
||||
}
|
||||
|
||||
export class IntegrationEventEmitter implements EventEmitter {
|
||||
private readonly listeners = new Map<string, Set<EventCallback>>()
|
||||
|
||||
on<T = any>(event: string, callback: EventCallback<T>): () => void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set())
|
||||
}
|
||||
|
||||
const eventListeners = this.listeners.get(event)
|
||||
if (eventListeners === undefined) {
|
||||
console.error(`Event '${event}' not found in listeners map`)
|
||||
return () => {}
|
||||
}
|
||||
eventListeners.add(callback)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
eventListeners.delete(callback)
|
||||
if (eventListeners.size === 0) {
|
||||
this.listeners.delete(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit<T = any>(event: string, data: T): void {
|
||||
const eventListeners = this.listeners.get(event)
|
||||
if (eventListeners === undefined) return
|
||||
|
||||
// Convert to array to avoid issues if listeners are modified during iteration
|
||||
const callbacks = Array.from(eventListeners)
|
||||
|
||||
callbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(data)
|
||||
} catch (error) {
|
||||
console.error(`Error in event listener for '${event}':`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
off (event: string, callback?: EventCallback): void {
|
||||
if (callback == null) {
|
||||
this.listeners.delete(event)
|
||||
return
|
||||
}
|
||||
|
||||
const eventListeners = this.listeners.get(event)
|
||||
if (eventListeners !== undefined) {
|
||||
eventListeners.delete(callback)
|
||||
if (eventListeners.size === 0) {
|
||||
this.listeners.delete(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeAllListeners (event?: string): void {
|
||||
if (event !== undefined) {
|
||||
this.listeners.delete(event)
|
||||
} else {
|
||||
this.listeners.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Utility method to get listener count
|
||||
listenerCount (event: string): number {
|
||||
return this.listeners.get(event)?.size ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
const GlobalIntegrationEventBus = {
|
||||
instance: null as IntegrationEventEmitter | null,
|
||||
|
||||
getInstance (): IntegrationEventEmitter {
|
||||
if (GlobalIntegrationEventBus.instance == null) {
|
||||
GlobalIntegrationEventBus.instance = new IntegrationEventEmitter()
|
||||
}
|
||||
return GlobalIntegrationEventBus.instance
|
||||
},
|
||||
|
||||
resetInstance (): void {
|
||||
GlobalIntegrationEventBus.instance = null
|
||||
}
|
||||
}
|
||||
|
||||
export const getIntegrationEventBus = (): IntegrationEventEmitter => {
|
||||
return GlobalIntegrationEventBus.getInstance()
|
||||
}
|
||||
|
||||
export function onIntegrationEvent<T = any> (
|
||||
event: string,
|
||||
callback: (data: T) => void,
|
||||
filter?: (data: T) => boolean
|
||||
): () => void {
|
||||
const eventBus = getIntegrationEventBus()
|
||||
|
||||
return eventBus.on(event, (data: T) => {
|
||||
if (filter === undefined || filter(data)) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
export * from './client'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
export * from './events'
|
||||
@@ -0,0 +1,301 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Integration, IntegrationKey, IntegrationSecret } from '@hcengineering/account-client'
|
||||
import { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core'
|
||||
|
||||
export type IntegrationStatus = 'active' | 'inactive' | 'connecting' | 'disconnecting' | 'error' | 'unknown'
|
||||
|
||||
export type ActionKind = 'connect' | 'disconnect' | 'reconnect' | 'update' | 'delete'
|
||||
|
||||
export interface IntegrationClient {
|
||||
/**
|
||||
* Get integrations
|
||||
*/
|
||||
getIntegrations: () => Promise<Integration[]>
|
||||
|
||||
/**
|
||||
* Get the connection details for a specific integration
|
||||
*/
|
||||
getConnection: (integration: Integration) => Promise<Integration | null>
|
||||
|
||||
/**
|
||||
* Integrate a social ID with a workspace
|
||||
*/
|
||||
integrate: (connection: Integration, workspace: WorkspaceUuid, data?: Record<string, any>) => Promise<Integration>
|
||||
|
||||
/**
|
||||
* Create a connection for a social ID (without workspace integration)
|
||||
*/
|
||||
connect: (socialId: PersonId, data?: Record<string, any>) => Promise<Integration>
|
||||
|
||||
/**
|
||||
* Update configuration for an existing integration
|
||||
*/
|
||||
updateConfig: (
|
||||
integrationKey: IntegrationKey,
|
||||
config: Record<string, any>,
|
||||
refresh?: () => Promise<void>
|
||||
) => Promise<void>
|
||||
|
||||
/**
|
||||
* Remove integration for a social ID and workspace
|
||||
*/
|
||||
removeIntegration: (
|
||||
socialId: PersonId | undefined | null,
|
||||
workspaceUuid: WorkspaceUuid | null | undefined
|
||||
) => Promise<
|
||||
| {
|
||||
connectionRemoved: boolean
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
|
||||
/**
|
||||
* Remove connection and all associated integrations
|
||||
*/
|
||||
removeConnection: (connection: Integration) => Promise<void>
|
||||
|
||||
/**
|
||||
* Set or update integration secret
|
||||
*/
|
||||
setSecret: (data: IntegrationSecret) => Promise<void>
|
||||
}
|
||||
|
||||
export interface IntegrationEventData {
|
||||
integration: Integration
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface IntegrationUpdatedData extends IntegrationEventData {
|
||||
oldConfig?: Record<string, any>
|
||||
newConfig: Record<string, any>
|
||||
}
|
||||
|
||||
export interface IntegrationErrorData {
|
||||
operation: string
|
||||
error: string
|
||||
integrationKey?: IntegrationKey
|
||||
socialId?: PersonId
|
||||
workspaceUuid?: WorkspaceUuid | null
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface IntegrationData {
|
||||
_id: string | undefined
|
||||
connectionId: string | undefined
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ActionRequest {
|
||||
/**
|
||||
* The type of action to perform
|
||||
*/
|
||||
type: ActionKind
|
||||
|
||||
/**
|
||||
* The ID of the integration to act upon
|
||||
*/
|
||||
integrationId: string
|
||||
|
||||
/**
|
||||
* Additional data required for the action, if any
|
||||
*/
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
/**
|
||||
* Whether the action was successful
|
||||
*/
|
||||
success: boolean
|
||||
|
||||
/**
|
||||
* Action result data, if any
|
||||
*/
|
||||
data?: Record<string, any>
|
||||
|
||||
/**
|
||||
* Error message if the action failed
|
||||
*/
|
||||
error?: string
|
||||
|
||||
/**
|
||||
* Updated integration status after the action
|
||||
*/
|
||||
status?: IntegrationStatus
|
||||
}
|
||||
|
||||
export interface IntegrationInfo {
|
||||
/**
|
||||
* Unique identifier for the integration
|
||||
*/
|
||||
id: string
|
||||
|
||||
/**
|
||||
* Integration kind (gmail, slack, github, etc.)
|
||||
*/
|
||||
kind: string
|
||||
|
||||
/**
|
||||
* Current status of the integration
|
||||
*/
|
||||
status: IntegrationStatus
|
||||
|
||||
/**
|
||||
* Last sync timestamp
|
||||
*/
|
||||
lastSync?: Date
|
||||
|
||||
/**
|
||||
* Error information if status is 'error'
|
||||
*/
|
||||
error?: {
|
||||
message: string
|
||||
code?: string
|
||||
timestamp: Date
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration-specific configuration
|
||||
*/
|
||||
export type IntegrationConfig = Record<string, any>
|
||||
|
||||
export interface ServiceClient {
|
||||
/**
|
||||
* Connect to an integration service
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @param config - Optional configuration
|
||||
* @returns Promise that resolves to connection result
|
||||
* @throws {ConnectionError} If connection fails
|
||||
*/
|
||||
connect: (workspace: WorkspaceUuid, integrationId: string, config?: IntegrationConfig) => Promise<ActionResponse>
|
||||
|
||||
/**
|
||||
* Disconnect from an integration service
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @param force - Whether to force disconnect even if cleanup fails
|
||||
* @returns Promise that resolves when disconnected
|
||||
* @throws {DisconnectionError} If disconnection fails
|
||||
*/
|
||||
disconnect: (workspace: WorkspaceUuid, integrationId: string, force?: boolean) => Promise<ActionResponse>
|
||||
|
||||
/**
|
||||
* Reconnect to an integration service (disconnect + connect)
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @param credentials - Updated authentication credentials
|
||||
* @returns Promise that resolves to reconnection result
|
||||
*/
|
||||
reconnect: (workspace: WorkspaceUuid, integrationId: string) => Promise<ActionResponse>
|
||||
|
||||
/**
|
||||
* Update integration configuration
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @param config - Configuration updates
|
||||
* @returns Promise that resolves when configuration is updated
|
||||
* @throws {ConfigurationError} If configuration is invalid
|
||||
*/
|
||||
configure: (
|
||||
workspace: WorkspaceUuid,
|
||||
integrationId: string,
|
||||
config: Partial<IntegrationConfig>
|
||||
) => Promise<ActionResponse>
|
||||
|
||||
/**
|
||||
* Test integration connection
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @returns Promise that resolves to test result
|
||||
*/
|
||||
test: (workspace: WorkspaceUuid, integrationId: string) => Promise<ActionResponse>
|
||||
|
||||
/**
|
||||
* Get integration information and status
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @returns Promise that resolves to integration info
|
||||
*/
|
||||
getInfo: (workspace: WorkspaceUuid, integrationId: string) => Promise<IntegrationInfo | null>
|
||||
|
||||
/**
|
||||
* Delete an integration permanently
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @param cleanup - Whether to cleanup associated data
|
||||
* @returns Promise that resolves when integration is deleted
|
||||
*/
|
||||
delete: (workspace: WorkspaceUuid, integrationId: string, cleanup?: boolean) => Promise<ActionResponse>
|
||||
|
||||
/**
|
||||
* Enable or disable an integration
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @param enabled - Whether to enable or disable
|
||||
* @returns Promise that resolves when status is updated
|
||||
*/
|
||||
setEnabled: (workspace: WorkspaceUuid, integrationId: string, enabled: boolean) => Promise<ActionResponse>
|
||||
|
||||
/**
|
||||
* Execute a custom action on the integration
|
||||
* @param workspace - The workspace UUID
|
||||
* @param integrationId - The integration identifier
|
||||
* @param action - Custom action to execute
|
||||
* @returns Promise that resolves to action result
|
||||
*/
|
||||
executeAction: (workspace: WorkspaceUuid, integrationId: string, action: ActionRequest) => Promise<ActionResponse>
|
||||
|
||||
getIntegrationKind: () => IntegrationKind
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration-specific error types
|
||||
*/
|
||||
export class IntegrationError extends Error {
|
||||
constructor (
|
||||
message: string,
|
||||
public code: string,
|
||||
public integrationId: string,
|
||||
public workspace: WorkspaceUuid
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'IntegrationError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnectionError extends IntegrationError {
|
||||
constructor (message: string, integrationId: string, workspace: WorkspaceUuid) {
|
||||
super(message, 'CONNECTION_ERROR', integrationId, workspace)
|
||||
this.name = 'ConnectionError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigurationError extends IntegrationError {
|
||||
constructor (message: string, integrationId: string, workspace: WorkspaceUuid) {
|
||||
super(message, 'CONFIGURATION_ERROR', integrationId, workspace)
|
||||
this.name = 'ConfigurationError'
|
||||
}
|
||||
}
|
||||
|
||||
export class DisconnectionError extends IntegrationError {
|
||||
constructor (message: string, integrationId: string, workspace: WorkspaceUuid) {
|
||||
super(message, 'DISCONNECTION_ERROR', integrationId, workspace)
|
||||
this.name = 'DisconnectionError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Integration } from '@hcengineering/account-client'
|
||||
import { IntegrationEventData } from './types'
|
||||
|
||||
export function isWorkspaceIntegration (integration: Integration): boolean {
|
||||
return integration.workspaceUuid != null
|
||||
}
|
||||
|
||||
export function isConnection (integration: Integration): boolean {
|
||||
return integration.workspaceUuid == null
|
||||
}
|
||||
|
||||
export function isSameIntegrationEvent (event: IntegrationEventData, integration: Integration): boolean {
|
||||
return (
|
||||
event.integration?.socialId === integration.socialId &&
|
||||
event.integration?.workspaceUuid === integration.workspaceUuid
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"declarationDir": "./types",
|
||||
"tsBuildInfoFile": ".build/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
import { DelayStrategyFactory } from '../delay'
|
||||
import { Retryable } from '../decorator'
|
||||
import { type RetryOptions } from '../retry'
|
||||
import { retryAllErrors } from '../retryable'
|
||||
|
||||
// Instead of mocking withRetry, we'll mock setTimeout to avoid waiting in tests
|
||||
jest.spyOn(global, 'setTimeout').mockImplementation((fn: any) => {
|
||||
@@ -38,6 +39,7 @@ describe('Retryable decorator', () => {
|
||||
maxDelayMs: 100,
|
||||
backoffFactor: 2
|
||||
}),
|
||||
isRetryable: retryAllErrors,
|
||||
logger: mockLogger
|
||||
}
|
||||
|
||||
@@ -88,7 +90,7 @@ describe('Retryable decorator', () => {
|
||||
async testMethod (): Promise<string> {
|
||||
this.callCount++
|
||||
if (this.callCount === 1) {
|
||||
throw new Error('First attempt failed')
|
||||
throw new Error('network error')
|
||||
}
|
||||
return 'success'
|
||||
}
|
||||
@@ -109,7 +111,7 @@ describe('Retryable decorator', () => {
|
||||
async incrementAndGet (): Promise<number> {
|
||||
if (this.counter === 0) {
|
||||
this.counter++
|
||||
throw new Error('First attempt failed')
|
||||
throw new Error('network error')
|
||||
}
|
||||
this.counter++
|
||||
return this.counter
|
||||
@@ -135,7 +137,8 @@ describe('Retryable decorator', () => {
|
||||
delayStrategy: DelayStrategyFactory.fixed({
|
||||
delayMs: 10
|
||||
}),
|
||||
logger: mockLogger
|
||||
logger: mockLogger,
|
||||
isRetryable: retryAllErrors
|
||||
})
|
||||
async alwaysFailingMethod (): Promise<string> {
|
||||
throw new Error('Persistent failure')
|
||||
@@ -189,7 +192,8 @@ describe('Retryable decorator', () => {
|
||||
delayStrategy: DelayStrategyFactory.fixed({
|
||||
delayMs: 10
|
||||
}),
|
||||
logger: mockLogger
|
||||
logger: mockLogger,
|
||||
isRetryable: retryAllErrors
|
||||
})
|
||||
async unstableMethod (): Promise<string> {
|
||||
callCount++
|
||||
@@ -228,7 +232,8 @@ describe('Retryable decorator', () => {
|
||||
maxDelayMs: 500,
|
||||
backoffFactor: 2,
|
||||
jitter: 0 // Disable jitter for predictable tests
|
||||
})
|
||||
}),
|
||||
isRetryable: retryAllErrors
|
||||
})
|
||||
async delayingMethod (): Promise<string> {
|
||||
callCount++
|
||||
@@ -266,7 +271,8 @@ describe('Retryable decorator', () => {
|
||||
delayStrategy: DelayStrategyFactory.fixed({
|
||||
delayMs: 100,
|
||||
jitter: 0 // Disable jitter for predictable tests
|
||||
})
|
||||
}),
|
||||
isRetryable: retryAllErrors
|
||||
})
|
||||
async method (): Promise<string> {
|
||||
callCount++
|
||||
@@ -290,7 +296,8 @@ describe('Retryable decorator', () => {
|
||||
baseDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
jitter: 0 // Disable jitter for predictable tests
|
||||
})
|
||||
}),
|
||||
isRetryable: retryAllErrors
|
||||
})
|
||||
async method (): Promise<string> {
|
||||
callCount++
|
||||
|
||||
@@ -150,6 +150,7 @@ describe('withRetry', () => {
|
||||
maxDelayMs: 1000,
|
||||
backoffFactor: 10 // Would normally go 50 -> 500 -> 5000, but should cap at 1000
|
||||
}),
|
||||
isRetryable: retryAllErrors,
|
||||
logger: mockLogger
|
||||
}
|
||||
|
||||
@@ -194,6 +195,7 @@ describe('withRetry', () => {
|
||||
delayMs: 200,
|
||||
jitter: 0
|
||||
}),
|
||||
isRetryable: retryAllErrors,
|
||||
logger: mockLogger
|
||||
}
|
||||
|
||||
@@ -220,6 +222,7 @@ describe('withRetry', () => {
|
||||
maxDelayMs: 10000,
|
||||
jitter: 0
|
||||
}),
|
||||
isRetryable: retryAllErrors,
|
||||
logger: mockLogger
|
||||
}
|
||||
|
||||
@@ -253,6 +256,7 @@ describe('createRetryableFunction', () => {
|
||||
maxDelayMs: 100,
|
||||
backoffFactor: 2
|
||||
}),
|
||||
isRetryable: retryAllErrors,
|
||||
logger: mockLogger
|
||||
}
|
||||
|
||||
@@ -294,12 +298,12 @@ describe('createRetryableFunction', () => {
|
||||
})
|
||||
|
||||
it('should propagate the final error if all retries fail', async () => {
|
||||
const mockError = new Error('persistent failure')
|
||||
const mockError = new Error('network error')
|
||||
const mockFn = jest.fn().mockRejectedValue(mockError)
|
||||
|
||||
const retryableFn = createRetryableFunction(mockFn, mockOptions)
|
||||
|
||||
await expect(retryableFn()).rejects.toThrow('persistent failure')
|
||||
await expect(retryableFn()).rejects.toThrow('network error')
|
||||
|
||||
expect(mockOptions.maxRetries).toBeDefined()
|
||||
expect(mockFn).toHaveBeenCalledTimes(mockOptions.maxRetries ?? -1)
|
||||
@@ -314,7 +318,7 @@ describe('Using retry in class methods', () => {
|
||||
async unstableFunction (): Promise<string> {
|
||||
this.counter++
|
||||
if (this.counter < 3) {
|
||||
throw new Error(`Failure attempt ${this.counter}`)
|
||||
throw new Error(`network error ${this.counter}`)
|
||||
}
|
||||
return 'success'
|
||||
}
|
||||
@@ -461,12 +465,12 @@ describe('withRetry with isRetryable option', () => {
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('non-retriable error'), expect.any(Object))
|
||||
})
|
||||
|
||||
it('should use the default retryAllErrors if isRetryable is not provided', async () => {
|
||||
it('should use the default retryNetworkErrors if isRetryable is not provided', async () => {
|
||||
// All errors should be retried by default
|
||||
const mockOperation = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('error 1'))
|
||||
.mockRejectedValueOnce(new Error('error 2'))
|
||||
.mockRejectedValueOnce(new Error('unreachable'))
|
||||
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
|
||||
.mockResolvedValueOnce('success')
|
||||
|
||||
const result = await withRetry(mockOperation, {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
import { defaultLogger, type Logger } from './logger'
|
||||
import { type IsRetryable, retryAllErrors } from './retryable'
|
||||
import { type IsRetryable, retryNetworkErrors } from './retryable'
|
||||
import { type DelayStrategy, DelayStrategyFactory, sleep } from './delay'
|
||||
|
||||
/**
|
||||
@@ -35,7 +35,7 @@ export interface RetryOptions {
|
||||
*/
|
||||
export const DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
||||
maxRetries: 5,
|
||||
isRetryable: retryAllErrors,
|
||||
isRetryable: retryNetworkErrors,
|
||||
delayStrategy: DelayStrategyFactory.exponentialBackoff({
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 30000,
|
||||
|
||||
@@ -393,6 +393,18 @@
|
||||
--calendar-event-back-color: #2B2D41;
|
||||
--calendar-event-border-color: rgba(43, 81, 144, 0.2);
|
||||
--calendar-event-handle-color: #2b5190;
|
||||
|
||||
--theme-label-green-color: rgba(34, 197, 94, .8);
|
||||
--theme-label-green-bg-color: rgba(34, 197, 94, .08);
|
||||
--theme-label-green-border-color: rgba(34, 197, 94, .2);
|
||||
|
||||
--theme-label-blue-color: rgba(59, 130, 246, .8);
|
||||
--theme-label-blue-bg-color: rgba(59, 130, 246, .08);
|
||||
--theme-label-blue-border-color: rgba(59, 130, 246, .2);
|
||||
|
||||
--theme-label-gray-color: rgba(255, 255, 255, .6);
|
||||
--theme-label-gray-bg-color: rgba(255, 255, 255, .02);
|
||||
--theme-label-gray-border-color: rgba(255, 255, 255, .09);
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
@@ -693,4 +705,16 @@
|
||||
--theme-button-contrast-disabled: rgba(0, 0, 0, .6);
|
||||
--theme-button-contrast-disabled-color: rgba(255, 255, 255, .5);
|
||||
--theme-button-contrast-border: rgba(0, 0, 0, .2);
|
||||
|
||||
--theme-label-green-color: rgba(22, 130, 60, 0.9);
|
||||
--theme-label-green-bg-color: rgba(22, 163, 74, .08);
|
||||
--theme-label-green-border-color: rgba(22, 163, 74, .15);
|
||||
|
||||
--theme-label-blue-color: rgba(37, 99, 235, 0.9);
|
||||
--theme-label-blue-bg-color: rgba(59, 130, 246, .08);
|
||||
--theme-label-blue-border-color: rgba(59, 130, 246, .15);
|
||||
|
||||
--theme-label-gray-color: rgba(0, 0, 0, .6);
|
||||
--theme-label-gray-bg-color: rgba(0, 0, 0, .02);
|
||||
--theme-label-gray-border-color: rgba(0, 0, 0, .09)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
"Language": "Jazyk",
|
||||
"DaysWOValue": "{days, plural, =1 {den} other {dny}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {týden} other {týdny}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {měsíc} other {měsíce}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {měsíc} other {měsíce}}",
|
||||
"Filter": "Filtr",
|
||||
"Clear": "Vymazat"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "Sprache",
|
||||
"DaysWOValue": "{days, plural, =1 {Tag} other {Tage}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {Woche} other {Wochen}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {Monat} other {Monate}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {Monat} other {Monate}}",
|
||||
"Filter": "Filter",
|
||||
"Clear": "Löschen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "Language",
|
||||
"DaysWOValue": "{days, plural, =1 {day} other {days}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {week} other {weeks}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {month} other {months}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {month} other {months}}",
|
||||
"Filter": "Filter",
|
||||
"Clear": "Clear"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "Idioma",
|
||||
"DaysWOValue": "{days, plural, =1 {día} other {días}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {semana} other {semanas}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {mes} other {meses}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {mes} other {meses}}",
|
||||
"Filter": "Filtrar",
|
||||
"Clear": "Limpiar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "Langue",
|
||||
"DaysWOValue": "{days, plural, =1 {jour} other {jours}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {semaine} other {semaines}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {mois} other {mois}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {mois} other {mois}}",
|
||||
"Filter": "Filtrer",
|
||||
"Clear": "Effacer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "Lingua",
|
||||
"DaysWOValue": "{days, plural, =1 {giorno} other {giorni}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {settimana} other {settimane}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {mese} other {mesi}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {mese} other {mesi}}",
|
||||
"Filter": "Filtra",
|
||||
"Clear": "Cancella"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "言語",
|
||||
"DaysWOValue": "{days, plural, =1 {日} other {日}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {週} other {週}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {か月} other {か月}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {か月} other {か月}}",
|
||||
"Filter": "フィルター",
|
||||
"Clear": "クリア"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "Idioma",
|
||||
"DaysWOValue": "{days, plural, =1 {dia} other {dias}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {semana} other {semanas}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {mês} other {meses}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {mês} other {meses}}",
|
||||
"Filter": "Filtrar",
|
||||
"Clear": "Limpar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "Язык",
|
||||
"DaysWOValue": "{days, plural, one {день} few {дня} other {дней}}",
|
||||
"WeeksWOValue": "{weeks, plural, one {неделя} few {недели} other {недель}}",
|
||||
"MonthsWOValue": "{months, plural, one {месяц} few {месяца} other {месяцев}}"
|
||||
"MonthsWOValue": "{months, plural, one {месяц} few {месяца} other {месяцев}}",
|
||||
"Filter": "Фильтр",
|
||||
"Clear": "Очистить"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"Language": "语言",
|
||||
"DaysWOValue": "{days, plural, =1 {天} other {天}}",
|
||||
"WeeksWOValue": "{weeks, plural, =1 {周} other {周}}",
|
||||
"MonthsWOValue": "{months, plural, =1 {月} other {月}}"
|
||||
"MonthsWOValue": "{months, plural, =1 {月} other {月}}",
|
||||
"Filter": "过滤",
|
||||
"Clear": "清除"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
{/if}
|
||||
{#if loading}
|
||||
<div
|
||||
class="btn-icon pointer-events-none spinner"
|
||||
class="btn-icon pointer-events-none spinner pl-2"
|
||||
class:resetIconSize={resetIconSize === 'icon'}
|
||||
style:color={primary ? 'var(--primary-button-color)' : 'var(--theme-caption-color)'}
|
||||
>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
export let size: ButtonSize = 'small'
|
||||
export let justify: 'left' | 'center' = 'center'
|
||||
export let width: string | undefined = undefined
|
||||
export let minWidth: string | undefined = undefined
|
||||
export let labelDirection: TooltipAlignment | undefined = undefined
|
||||
export let shouldUpdateUndefined: boolean = true
|
||||
export let minW0 = true
|
||||
@@ -86,6 +87,7 @@
|
||||
{icon}
|
||||
{iconProps}
|
||||
width={width ?? 'min-content'}
|
||||
{minWidth}
|
||||
{size}
|
||||
{kind}
|
||||
{disabled}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script context="module" lang="ts">
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Button from './Button.svelte'
|
||||
import IconFilter from './icons/Filter.svelte'
|
||||
import IconClose from './icons/Close.svelte'
|
||||
import { eventToHTMLElement, showPopup } from '../popups'
|
||||
import FilterCategoryPopup from './FilterCategoryPopup.svelte'
|
||||
import type { ButtonKind, ActiveFilter, FilterCategory } from '../types'
|
||||
import ui from '../plugin'
|
||||
|
||||
export let categories: FilterCategory[] = []
|
||||
export let activeFilters: ActiveFilter[] = []
|
||||
export let disabled: boolean = false
|
||||
export let size: 'x-small' | 'small' | 'medium' | 'large' = 'medium'
|
||||
export let kind: ButtonKind = 'regular'
|
||||
export let showLabel: boolean = true
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: ActiveFilter[]
|
||||
}>()
|
||||
|
||||
function clearFilters (): void {
|
||||
dispatch('change', [])
|
||||
}
|
||||
|
||||
function addFilter (filter: ActiveFilter): void {
|
||||
// Remove any existing filter for the same category and add the new one
|
||||
const filtered = activeFilters.filter((f) => f.categoryId !== filter.categoryId)
|
||||
dispatch('change', [...filtered, filter])
|
||||
}
|
||||
|
||||
function removeFilter (categoryId: string): void {
|
||||
dispatch(
|
||||
'change',
|
||||
activeFilters.filter((f) => f.categoryId !== categoryId)
|
||||
)
|
||||
}
|
||||
|
||||
function showFilterPopup (e: MouseEvent): void {
|
||||
const target = eventToHTMLElement(e)
|
||||
showPopup(
|
||||
FilterCategoryPopup,
|
||||
{
|
||||
categories,
|
||||
activeFilters,
|
||||
onFilterChange: addFilter,
|
||||
onFilterRemove: removeFilter
|
||||
},
|
||||
target
|
||||
)
|
||||
}
|
||||
|
||||
$: hasFilters = activeFilters.length > 0
|
||||
</script>
|
||||
|
||||
<Button
|
||||
icon={hasFilters ? IconClose : IconFilter}
|
||||
label={showLabel ? (hasFilters ? ui.string.Clear : ui.string.Filter) : undefined}
|
||||
{kind}
|
||||
{size}
|
||||
pressed={hasFilters}
|
||||
{disabled}
|
||||
on:click={(ev) => {
|
||||
if (hasFilters) {
|
||||
clearFilters()
|
||||
} else {
|
||||
showFilterPopup(ev)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,238 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import type { FilterCategory, FilterOption, ActiveFilter } from '../types'
|
||||
import IconChevronLeft from './icons/ChevronLeft.svelte'
|
||||
import IconCheck from './icons/Check.svelte'
|
||||
import Label from './Label.svelte'
|
||||
|
||||
export let categories: FilterCategory[]
|
||||
export let activeFilters: ActiveFilter[]
|
||||
export let onFilterChange: (filter: ActiveFilter) => void
|
||||
export let onFilterRemove: (categoryId: string) => void
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// Navigation state
|
||||
let selectedCategory: FilterCategory | null = null
|
||||
let view: 'categories' | 'options' = 'categories'
|
||||
|
||||
function selectCategory (category: FilterCategory): void {
|
||||
selectedCategory = category
|
||||
view = 'options'
|
||||
}
|
||||
|
||||
function goBackToCategories (): void {
|
||||
selectedCategory = null
|
||||
view = 'categories'
|
||||
}
|
||||
|
||||
function selectOption (option: FilterOption): void {
|
||||
if (selectedCategory === null) return
|
||||
|
||||
const filter: ActiveFilter = {
|
||||
categoryId: selectedCategory.id,
|
||||
optionId: option.id,
|
||||
categoryLabel: selectedCategory.label,
|
||||
optionLabel: option.label
|
||||
}
|
||||
onFilterChange(filter)
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
function clearCategoryFilter (): void {
|
||||
if (selectedCategory === null) return
|
||||
onFilterRemove(selectedCategory.id)
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
function isActive (categoryId: string): boolean {
|
||||
return activeFilters.some((f) => f.categoryId === categoryId)
|
||||
}
|
||||
|
||||
function isOptionSelected (optionId: string): boolean {
|
||||
if (selectedCategory === null) return false
|
||||
return activeFilters.some((f) => f.categoryId === selectedCategory?.id && f.optionId === optionId)
|
||||
}
|
||||
|
||||
function getActiveOption (categoryId: string): string {
|
||||
const filter = activeFilters.find((f) => f.categoryId === categoryId)
|
||||
return filter !== undefined ? filter.optionLabel : ''
|
||||
}
|
||||
|
||||
$: currentActiveFilter = (() => {
|
||||
if (selectedCategory === null) return null
|
||||
return activeFilters.find((f) => f.categoryId === selectedCategory?.id) ?? null
|
||||
})()
|
||||
</script>
|
||||
|
||||
<div class="filter-popup">
|
||||
{#if view === 'categories'}
|
||||
<!-- Categories View -->
|
||||
<div class="popup-header">
|
||||
<span class="popup-title">Filter by</span>
|
||||
</div>
|
||||
<div class="category-list">
|
||||
{#each categories as category (category.id)}
|
||||
<button
|
||||
class="category-item"
|
||||
class:active={isActive(category.id)}
|
||||
on:click={() => {
|
||||
selectCategory(category)
|
||||
}}
|
||||
>
|
||||
<span class="category-label"><Label label={category.label} /></span>
|
||||
{#if isActive(category.id)}
|
||||
<span class="active-value">{getActiveOption(category.id)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if view === 'options' && selectedCategory !== null}
|
||||
<!-- Options View -->
|
||||
<div class="popup-header">
|
||||
<button class="back-button" on:click={goBackToCategories}>
|
||||
<IconChevronLeft size={'small'} />
|
||||
</button>
|
||||
<span class="popup-title"><Label label={selectedCategory.label} /></span>
|
||||
</div>
|
||||
<div class="option-list">
|
||||
{#each selectedCategory.options as option (option.id)}
|
||||
<button
|
||||
class="option-item"
|
||||
class:selected={isOptionSelected(option.id)}
|
||||
on:click={() => {
|
||||
selectOption(option)
|
||||
}}
|
||||
>
|
||||
<span class="option-label"><Label label={option.label} /></span>
|
||||
{#if isOptionSelected(option.id)}
|
||||
<IconCheck size={'small'} />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if currentActiveFilter !== null}
|
||||
<div class="divider"></div>
|
||||
<button class="option-item clear-option" on:click={clearCategoryFilter}>
|
||||
<span class="option-label">Clear filter</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--theme-popup-color);
|
||||
border: 1px solid var(--theme-popup-divider);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--theme-popup-shadow);
|
||||
min-width: 12rem;
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--theme-popup-divider);
|
||||
background: var(--theme-bg-accent-color);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--theme-content-color);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-bg-accent-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
|
||||
.category-list,
|
||||
.option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.category-item,
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--theme-content-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-bg-accent-hover);
|
||||
}
|
||||
|
||||
&.active,
|
||||
&.selected {
|
||||
background: var(--theme-primary-bg-color);
|
||||
color: var(--theme-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.option-item.clear-option {
|
||||
color: var(--theme-warning-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-warning-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.category-label,
|
||||
.option-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.active-value {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--theme-popup-divider);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { FilterCategory, FilterOption, ActiveFilter } from '../types'
|
||||
import IconCheck from './icons/Check.svelte'
|
||||
|
||||
export let category: FilterCategory
|
||||
export let activeFilters: ActiveFilter[] = []
|
||||
export let onFilterChange: (filter: ActiveFilter) => void
|
||||
export let onFilterRemove: (categoryId: string) => void
|
||||
|
||||
function selectOption (option: FilterOption): void {
|
||||
const filter: ActiveFilter = {
|
||||
categoryId: category.id,
|
||||
optionId: option.id,
|
||||
categoryLabel: category.label,
|
||||
optionLabel: option.label
|
||||
}
|
||||
onFilterChange(filter)
|
||||
}
|
||||
|
||||
function clearFilter (): void {
|
||||
onFilterRemove(category.id)
|
||||
}
|
||||
|
||||
function isSelected (optionId: string): boolean {
|
||||
return activeFilters.some((f) => f.categoryId === category.id && f.optionId === optionId)
|
||||
}
|
||||
|
||||
$: activeFilter = activeFilters.find((f) => f.categoryId === category.id)
|
||||
</script>
|
||||
|
||||
<div class="filter-option-popup">
|
||||
<div class="popup-header">
|
||||
<span class="popup-title">{category.label}</span>
|
||||
</div>
|
||||
<div class="option-list">
|
||||
{#each category.options as option (option.id)}
|
||||
<button
|
||||
class="option-item"
|
||||
class:selected={isSelected(option.id)}
|
||||
on:click={() => {
|
||||
selectOption(option)
|
||||
}}
|
||||
>
|
||||
<span class="option-label">{option.label}</span>
|
||||
{#if isSelected(option.id)}
|
||||
<IconCheck size={'small'} />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if activeFilter}
|
||||
<div class="divider"></div>
|
||||
<button class="option-item clear-option" on:click={clearFilter}>
|
||||
<span class="option-label">Clear filter</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-option-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--theme-popup-color);
|
||||
border: 1px solid var(--theme-popup-divider);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--theme-popup-shadow);
|
||||
min-width: 10rem;
|
||||
max-width: 16rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--theme-popup-divider);
|
||||
background: var(--theme-bg-accent-color);
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
|
||||
.option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--theme-content-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-bg-accent-hover);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--theme-primary-bg-color);
|
||||
color: var(--theme-primary-color);
|
||||
}
|
||||
|
||||
&.clear-option {
|
||||
color: var(--theme-warning-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-warning-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--theme-popup-divider);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -58,6 +58,8 @@ export { default as Label } from './components/Label.svelte'
|
||||
export { default as Button } from './components/Button.svelte'
|
||||
export { default as ButtonWithDropdown } from './components/ButtonWithDropdown.svelte'
|
||||
export { default as ButtonGroup } from './components/ButtonGroup.svelte'
|
||||
export { default as FilterButton } from './components/FilterButton.svelte'
|
||||
export { default as FilterCategoryPopup } from './components/FilterCategoryPopup.svelte'
|
||||
export { default as Status } from './components/Status.svelte'
|
||||
export { default as StateTag } from './components/StateTag.svelte'
|
||||
export { default as Component } from './components/Component.svelte'
|
||||
|
||||
@@ -145,6 +145,9 @@ export const uis = plugin(uiId, {
|
||||
WeeksWOValue: '' as IntlString,
|
||||
MonthsWOValue: '' as IntlString,
|
||||
|
||||
Filter: '' as IntlString,
|
||||
Clear: '' as IntlString,
|
||||
|
||||
ReadOnlyModeWarning: '' as IntlString
|
||||
},
|
||||
metadata: {
|
||||
|
||||
@@ -564,3 +564,21 @@ export interface IPanelState extends IHeaderState {
|
||||
panelWidth: number
|
||||
innerWidth: number
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
id: string
|
||||
label: IntlString
|
||||
}
|
||||
|
||||
export interface FilterCategory {
|
||||
id: string
|
||||
label: IntlString
|
||||
options: FilterOption[]
|
||||
}
|
||||
|
||||
export interface ActiveFilter {
|
||||
categoryId: string
|
||||
optionId: string
|
||||
categoryLabel: IntlString
|
||||
optionLabel: IntlString
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChannelProvider } from '@hcengineering/contact'
|
||||
import { AnyAttribute, AttachedDoc, Class, Doc, Mixin, Ref } from '@hcengineering/core'
|
||||
import { AnyAttribute, AttachedDoc, Class, Doc, IntegrationKind, Mixin, Ref } from '@hcengineering/core'
|
||||
import { ExpertKnowledge, InitialKnowledge, MeaningfullKnowledge } from '@hcengineering/tags'
|
||||
import { ProjectType } from '@hcengineering/task'
|
||||
|
||||
@@ -375,3 +375,8 @@ string,
|
||||
urlShow: string
|
||||
}
|
||||
>
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const bitrixIntegrationKind = 'bitrix' as IntegrationKind
|
||||
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "Předposlední",
|
||||
"Last": "Poslední",
|
||||
"Weekday": "Všední den",
|
||||
"WeekendDay": "Víkendový den"
|
||||
"WeekendDay": "Víkendový den",
|
||||
"NotConnectedIntegration": "Účet {email} není integrován s pracovním prostorem",
|
||||
"SynchronizedCalendars": "Synchronizované kalendáře",
|
||||
"Account": "Účet",
|
||||
"NoCalendars": "Kalendáře pro synchronizaci nejsou vybrány"
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "Vorletzter",
|
||||
"Last": "Letzter",
|
||||
"Weekday": "Wochentag",
|
||||
"WeekendDay": "Wochenendtag"
|
||||
"WeekendDay": "Wochenendtag",
|
||||
"NotConnectedIntegration": "Das Konto {email} ist nicht mit dem Arbeitsbereich integriert",
|
||||
"SynchronizedCalendars": "Synchronisierte Kalender",
|
||||
"Account": "Konto",
|
||||
"NoCalendars": "Keine Kalender zur Synchronisation ausgewählt"
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
"HideDetails": "Hide details",
|
||||
"ReccuringEvent": "Recurring event",
|
||||
"ExternalParticipants": "External participants",
|
||||
"IntegrationDescr": "Integrate with Google Calendar",
|
||||
"IntegrationDescr": "Sync events and tasks with your Google Calendar",
|
||||
"Connect": "Connect",
|
||||
"RedirectGoogle": "You will be redirected to the Google authentication page. Please make sure to select all permissions.",
|
||||
"ConnectCalendar": "Connect Google Calendar account",
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "Next to last",
|
||||
"Last": "Last",
|
||||
"Weekday": "Weekday",
|
||||
"WeekendDay": "Weekend day"
|
||||
"WeekendDay": "Weekend day",
|
||||
"NotConnectedIntegration": "The account {email} is not integrated with the workspace",
|
||||
"SynchronizedCalendars": "Synchronized calendars",
|
||||
"Account": "Account",
|
||||
"NoCalendars": "No calendars selected for synchronization"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "Penúltimo",
|
||||
"Last": "Último",
|
||||
"Weekday": "Día laborable",
|
||||
"WeekendDay": "Día de descanso"
|
||||
"WeekendDay": "Día de descanso",
|
||||
"NotConnectedIntegration": "La cuenta {email} no está integrada con el espacio de trabajo",
|
||||
"SynchronizedCalendars": "Calendarios sincronizados",
|
||||
"Account": "Cuenta",
|
||||
"NoCalendars": "No se seleccionaron calendarios para la sincronización"
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "Avant-dernier",
|
||||
"Last": "Dernier",
|
||||
"Weekday": "Jour de la semaine",
|
||||
"WeekendDay": "Jour de congé"
|
||||
"WeekendDay": "Jour de congé",
|
||||
"NotConnectedIntegration": "Le compte {email} n'est pas intégré au workspace",
|
||||
"SynchronizedCalendars": "Calendriers synchronisés",
|
||||
"Account": "Compte",
|
||||
"NoCalendars": "Aucun calendrier sélectionné pour la synchronisation"
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "Penultimo",
|
||||
"Last": "Ultimo",
|
||||
"Weekday": "Giorno lavorativo",
|
||||
"WeekendDay": "Giorno libero"
|
||||
"WeekendDay": "Giorno libero",
|
||||
"NotConnectedIntegration": "L'account {email} non è integrato con il workspace",
|
||||
"SynchronizedCalendars": "Calendari sincronizzati",
|
||||
"Account": "Account",
|
||||
"NoCalendars": "Nessun calendario selezionato per la sincronizzazione"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,10 @@
|
||||
"NextToLast": "最後から2番目",
|
||||
"Last": "最後",
|
||||
"Weekday": "平日",
|
||||
"WeekendDay": "休日"
|
||||
"WeekendDay": "休日",
|
||||
"NotConnectedIntegration": "アカウント {email} はワークスペースに統合されていません",
|
||||
"SynchronizedCalendars": "同期されたカレンダー",
|
||||
"Account": "アカウント",
|
||||
"NoCalendars": "同期するカレンダーが選択されていません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "Penúltimo",
|
||||
"Last": "Último",
|
||||
"Weekday": "Dia da semana",
|
||||
"WeekendDay": "Folga"
|
||||
"WeekendDay": "Folga",
|
||||
"NotConnectedIntegration": "A conta {email} não está integrada com o espaço de trabalho",
|
||||
"SynchronizedCalendars": "Calendários sincronizados",
|
||||
"Account": "Conta",
|
||||
"NoCalendars": "Nenhum calendário encontrado"
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "Предпоследний",
|
||||
"Last": "Последний",
|
||||
"Weekday": "Будний день",
|
||||
"WeekendDay": "Выходной день"
|
||||
"WeekendDay": "Выходной день",
|
||||
"NotConnectedIntegration": "Учетная запись {email} не интегрирована с рабочим пространством",
|
||||
"SynchronizedCalendars": "Синхронизированные календари",
|
||||
"Account": "Аккаунт",
|
||||
"NoCalendars": "Календари для синхронизации не выбраны"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@
|
||||
"NextToLast": "倒数第二个",
|
||||
"Last": "最后一个",
|
||||
"Weekday": "工作日",
|
||||
"WeekendDay": "周末日"
|
||||
"WeekendDay": "周末日",
|
||||
"NotConnectedIntegration": "帐户 {email} 未与工作区集成",
|
||||
"SynchronizedCalendars": "已同步的日历",
|
||||
"Account": "帐户",
|
||||
"NoCalendars": "未选择同步日历"
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
"@hcengineering/view-resources": "^0.6.0",
|
||||
"@hcengineering/view": "^0.6.13",
|
||||
"@hcengineering/workbench": "^0.6.16",
|
||||
"@hcengineering/integration-client": "^0.6.0",
|
||||
"fast-equals": "^5.2.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"date-fns-tz": "^2.0.0"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { concatLink } from '@hcengineering/core'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import login from '@hcengineering/login'
|
||||
import { type Integration } from '@hcengineering/account-client'
|
||||
import {
|
||||
type IntegrationClient,
|
||||
getIntegrationClient as getIntegrationClientRaw
|
||||
} from '@hcengineering/integration-client'
|
||||
import calendar from './plugin'
|
||||
import { calendarIntegrationKind } from '@hcengineering/calendar'
|
||||
|
||||
export async function signout (integration: Integration, client: IntegrationClient): Promise<void> {
|
||||
const url = getMetadata(calendar.metadata.CalendarServiceURL)
|
||||
const token = getMetadata(presentation.metadata.Token)
|
||||
if (url === undefined || token === undefined) {
|
||||
throw new Error('Calendar service URL or token is not defined')
|
||||
}
|
||||
const connection = await client.getConnection(integration)
|
||||
const email = integration.data?.email ?? connection?.data?.email
|
||||
if (email === undefined) {
|
||||
throw new Error('Email is not defined in integration')
|
||||
}
|
||||
await fetch(concatLink(url, `/signout?value=${email}`), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function disconnect (integration: Integration): Promise<void> {
|
||||
const integrationClient = await getIntegrationClient()
|
||||
const result = await integrationClient.removeIntegration(integration.socialId, integration.workspaceUuid)
|
||||
if (result !== undefined && result.connectionRemoved) {
|
||||
await signout(integration, integrationClient)
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectAll (integration: Integration): Promise<void> {
|
||||
const integrationClient = await getIntegrationClient()
|
||||
await integrationClient.removeConnection(integration)
|
||||
await signout(integration, integrationClient)
|
||||
}
|
||||
|
||||
export async function getIntegrationClient (): Promise<IntegrationClient> {
|
||||
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
|
||||
const token = getMetadata(presentation.metadata.Token)
|
||||
if (accountsUrl === undefined || token === undefined) {
|
||||
throw new Error('Accounts URL or token is not defined')
|
||||
}
|
||||
return getIntegrationClientRaw(accountsUrl, token, calendarIntegrationKind, 'calendar')
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
getCurrentWorkspaceUuid
|
||||
} from '@hcengineering/presentation'
|
||||
import { Label, Modal, CheckBox, Spinner, Button, IconCopy, EditBox } from '@hcengineering/ui'
|
||||
import calendar from '@hcengineering/calendar'
|
||||
import calendar, { caldavIntegrationKind } from '@hcengineering/calendar'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { quintOut } from 'svelte/easing'
|
||||
@@ -72,7 +72,7 @@
|
||||
const accountClient = getAccountClient()
|
||||
const integrations = await accountClient.listIntegrations({
|
||||
socialId: socialId._id,
|
||||
kind: 'caldav'
|
||||
kind: caldavIntegrationKind
|
||||
})
|
||||
for (const integration of integrations) {
|
||||
if (integration.workspaceUuid == null) {
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
const accountClient = getAccountClient()
|
||||
const socialId = selectedSocialId._id
|
||||
const kind = 'caldav'
|
||||
const kind = caldavIntegrationKind
|
||||
const key = 'password'
|
||||
if (wasAccessEnabled && !accessEnabled) {
|
||||
// Access disabled
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
import { Calendar } from '@hcengineering/calendar'
|
||||
import { getCurrentAccount } from '@hcengineering/core'
|
||||
import presentation, { Card, createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { Integration } from '@hcengineering/setting'
|
||||
import { Grid, Label, Toggle, tooltip } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import type { Integration } from '@hcengineering/account-client'
|
||||
import calendar from '../plugin'
|
||||
|
||||
export let integration: Integration
|
||||
@@ -32,7 +32,7 @@
|
||||
calendar.class.ExternalCalendar,
|
||||
{
|
||||
createdBy: { $in: getCurrentAccount().socialIds },
|
||||
externalUser: integration.value
|
||||
externalUser: integration.data?.email
|
||||
},
|
||||
(res) => {
|
||||
calendars = res
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition'
|
||||
|
||||
import { Calendar } from '@hcengineering/calendar'
|
||||
import { getCurrentAccount } from '@hcengineering/core'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import { Label } from '@hcengineering/ui'
|
||||
import type { Integration } from '@hcengineering/account-client'
|
||||
import calendar from '../plugin'
|
||||
|
||||
export let integration: Integration
|
||||
|
||||
let calendars: Calendar[] = []
|
||||
let syncedCalendars: Calendar[] = []
|
||||
const query = createQuery()
|
||||
query.query(
|
||||
calendar.class.ExternalCalendar,
|
||||
{
|
||||
createdBy: { $in: getCurrentAccount().socialIds },
|
||||
externalUser: integration.data?.email
|
||||
},
|
||||
(res) => {
|
||||
calendars = res
|
||||
syncedCalendars = calendars.filter((c) => !c.hidden)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="integration-state">
|
||||
<div class="state-content">
|
||||
{#if integration.workspaceUuid == null}
|
||||
<div class="flex-center" transition:fade={{ duration: 300 }}>
|
||||
<span class="text-normal content-color">
|
||||
<Label label={calendar.string.NotConnectedIntegration} params={{ email: integration?.data?.email ?? '' }} />
|
||||
</span>
|
||||
</div>
|
||||
{:else if integration.data?.email !== undefined}
|
||||
<div class="stats-list" transition:fade={{ duration: 300 }}>
|
||||
<div class="stat-row">
|
||||
<span class="text-normal content-color font-medium">{integration.data?.email}</span>
|
||||
</div>
|
||||
<div class="space-divider bottom" />
|
||||
{#if syncedCalendars.length === 0}
|
||||
<div class="stat-row">
|
||||
<span class="text-normal content-color">{calendar.string.NoCalendars}</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each syncedCalendars as calendar (calendar._id)}
|
||||
<div class="stat-row">
|
||||
<span class="text-normal content-halfcontent-color">{calendar.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.integration-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.state-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 36 36"
|
||||
viewBox="3 3 30 30"
|
||||
style="enable-background:new 0 0 36 36;"
|
||||
xml:space="preserve"
|
||||
>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -14,9 +14,9 @@
|
||||
//
|
||||
|
||||
import { type ReccuringInstance } from '@hcengineering/calendar'
|
||||
import { type Doc, type TxOperations, concatLink } from '@hcengineering/core'
|
||||
import { type Resources, getMetadata } from '@hcengineering/platform'
|
||||
import presentation, { getClient } from '@hcengineering/presentation'
|
||||
import { type Doc, type TxOperations } from '@hcengineering/core'
|
||||
import { type Resources } from '@hcengineering/platform'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { closePopup, showPopup } from '@hcengineering/ui'
|
||||
import CalendarView from './components/CalendarView.svelte'
|
||||
import CreateEvent from './components/CreateEvent.svelte'
|
||||
@@ -44,10 +44,13 @@ import ConnectApp from './components/ConnectApp.svelte'
|
||||
import CalendarWidget from './components/CalendarWidget.svelte'
|
||||
import ScheduleNavSection from './components/ScheduleNavSection.svelte'
|
||||
import CalendarSettings from './components/CalendarSettings.svelte'
|
||||
import IntegrationState from './components/IntegrationState.svelte'
|
||||
import calendar from './plugin'
|
||||
import contact from '@hcengineering/contact'
|
||||
import { deleteObjects } from '@hcengineering/view-resources'
|
||||
import { eventTitleProvider, configureCalDavAccess } from './utils'
|
||||
import { type Integration } from '@hcengineering/account-client'
|
||||
import { disconnect, disconnectAll } from './api'
|
||||
|
||||
export {
|
||||
EventElement,
|
||||
@@ -198,24 +201,19 @@ export default async (): Promise<Resources> => ({
|
||||
IntegrationConfigure,
|
||||
ConnectApp,
|
||||
CalendarWidget,
|
||||
CalendarSettings
|
||||
CalendarSettings,
|
||||
IntegrationState
|
||||
},
|
||||
actionImpl: {
|
||||
SaveEventReminder: saveEventReminder,
|
||||
DeleteRecEvent: deleteRecEvent
|
||||
},
|
||||
handler: {
|
||||
DisconnectHandler: async (value: string) => {
|
||||
const url = getMetadata(calendar.metadata.CalendarServiceURL)
|
||||
const token = getMetadata(presentation.metadata.Token)
|
||||
if (url === undefined || token === undefined) return
|
||||
await fetch(concatLink(url, `/signout?value=${value}`), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
DisconnectHandler: async (integration: Integration) => {
|
||||
await disconnect(integration)
|
||||
},
|
||||
DisconnectAllHandler: async (integration: Integration): Promise<void> => {
|
||||
await disconnectAll(integration)
|
||||
}
|
||||
},
|
||||
function: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
Class,
|
||||
Client,
|
||||
Doc,
|
||||
IntegrationKind,
|
||||
Markup,
|
||||
Mixin,
|
||||
PersonId,
|
||||
@@ -173,6 +174,16 @@ export interface Schedule extends Doc {
|
||||
calendar?: Ref<Calendar>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const calendarIntegrationKind = 'google-calendar' as IntegrationKind
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const caldavIntegrationKind = 'caldav' as IntegrationKind
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -229,7 +240,8 @@ const calendarPlugin = plugin(calendarId, {
|
||||
DateTimePresenter: '' as AnyComponent,
|
||||
DocReminder: '' as AnyComponent,
|
||||
ConnectApp: '' as AnyComponent,
|
||||
ScheduleEditor: '' as AnyComponent
|
||||
ScheduleEditor: '' as AnyComponent,
|
||||
IntegrationState: '' as AnyComponent
|
||||
},
|
||||
string: {
|
||||
Title: '' as IntlString,
|
||||
@@ -278,10 +290,15 @@ const calendarPlugin = plugin(calendarId, {
|
||||
CalDavAccessPasswordWarning: '' as IntlString,
|
||||
MeetingScheduledNotification: '' as IntlString,
|
||||
MeetingRescheduledNotification: '' as IntlString,
|
||||
MeetingCanceledNotification: '' as IntlString
|
||||
MeetingCanceledNotification: '' as IntlString,
|
||||
NotConnectedIntegration: '' as IntlString,
|
||||
SynchronizedCalendars: '' as IntlString,
|
||||
Account: '' as IntlString,
|
||||
NoCalendars: '' as IntlString
|
||||
},
|
||||
handler: {
|
||||
DisconnectHandler: '' as Handler
|
||||
DisconnectHandler: '' as Handler,
|
||||
DisconnectAllHandler: '' as Handler
|
||||
},
|
||||
integrationType: {
|
||||
Calendar: '' as Ref<IntegrationType>
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Rozšíření pro integraci e-mailů Gmail",
|
||||
"GooglePrivacy": "Použití a přenos informací získaných z Google API do jakékoli jiné aplikace bude v souladu s <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">zásadami Google API Services User Data Policy</a>, včetně požadavků na omezené použití.",
|
||||
"EmailNotificationsDescription": "Dostávejte osobní oznámení na e-mail.",
|
||||
"FailedToConnect": "Nepodařilo se připojit k Gmail"
|
||||
"FailedToConnect": "Služba Gmail není k dispozici",
|
||||
"TotalMessages": "Celkový počet zpráv",
|
||||
"FailedToLoadState": "Nepodařilo se načíst stav Gmailu",
|
||||
"NotConnectedIntegration": "Účet {email} není integrován s pracovním prostorem"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Erweiterung für Gmail-Integration",
|
||||
"GooglePrivacy": "Die Nutzung und Übertragung von Informationen, die Huly von Google-APIs erhält, an andere Apps erfolgt gemäß der <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, einschließlich der Anforderungen zur eingeschränkten Nutzung.",
|
||||
"EmailNotificationsDescription": "Erhalten Sie persönliche Benachrichtigungen per E-Mail.",
|
||||
"FailedToConnect": "Fehler beim Verbinden mit Gmail"
|
||||
"FailedToConnect": "Gmail-Dienst ist nicht verfügbar",
|
||||
"TotalMessages": "Gesamtanzahl der Nachrichten",
|
||||
"FailedToLoadState": "Fehler beim Laden des Gmail-Zustands",
|
||||
"NotConnectedIntegration": "Das Konto {email} ist nicht mit dem Arbeitsbereich integriert"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"string": {
|
||||
"MessageID": "MessageID",
|
||||
"IntegrationLabel": "Gmail",
|
||||
"IntegrationDescription": "Integrate with Gmail",
|
||||
"IntegrationDescription": "Connect your Gmail account to send, receive, and manage emails",
|
||||
"SharedMessages": "shared emails",
|
||||
"To": "To:",
|
||||
"From": "From:",
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Extension for Gmail email integration",
|
||||
"GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, including the Limited Use requirements.",
|
||||
"EmailNotificationsDescription": "Receive personal notifications on email.",
|
||||
"FailedToConnect": "Failed to connect to Gmail"
|
||||
"FailedToConnect": "Gmail service is not available",
|
||||
"TotalMessages": "Total messages",
|
||||
"FailedToLoadState": "Failed to load Gmail state",
|
||||
"NotConnectedIntegration": "The account {email} is not integrated with the workspace"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Extensión para la integración de correo electrónico de Gmail",
|
||||
"GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, including the Limited Use requirements.",
|
||||
"EmailNotificationsDescription": "Reciba notificaciones personales por correo electrónico.",
|
||||
"FailedToConnect": "Error al conectar con Gmail"
|
||||
"FailedToConnect": "El servicio de Gmail no está disponible",
|
||||
"TotalMessages": "Total de mensajes",
|
||||
"FailedToLoadState": "Error al cargar el estado de Gmail",
|
||||
"NotConnectedIntegration": "La cuenta {email} no está integrada con el espacio de trabajo"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Extension pour l'intégration des emails Gmail",
|
||||
"GooglePrivacy": "L'utilisation et le transfert des informations reçues des API Google par Huly à toute autre application respecteront les <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">règles d'utilisation des données utilisateur des services API Google</a>, y compris les exigences d'utilisation limitée.",
|
||||
"EmailNotificationsDescription": "Recevez des notifications personnelles par e-mail.",
|
||||
"FailedToConnect": "Impossible de se connecter au compte Gmail"
|
||||
"FailedToConnect": "Le service Gmail n'est pas disponible",
|
||||
"TotalMessages": "Total des messages",
|
||||
"FailedToLoadState": "Échec du chargement de l'état de Gmail",
|
||||
"NotConnectedIntegration": "Le compte {email} n'est pas intégré à l'espace de travail"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Estensione per l'integrazione delle email di Gmail",
|
||||
"GooglePrivacy": "L'uso e il trasferimento delle informazioni ricevute dalle API di Google a qualsiasi altra app di Huly adempirà alla <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Politica sui dati degli utenti dei servizi API di Google</a>, inclusi i requisiti di utilizzo limitato.",
|
||||
"EmailNotificationsDescription": "Ricevi notifiche personali sulle email.",
|
||||
"FailedToConnect": "Impossibile connettere l'account Gmail"
|
||||
"FailedToConnect": "Il servizio Gmail non è disponibile",
|
||||
"TotalMessages": "Totale messaggi",
|
||||
"FailedToLoadState": "Impossibile caricare lo stato di Gmail",
|
||||
"NotConnectedIntegration": "L'account {email} non è integrato con il workspace"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"ConfigLabel": "メール",
|
||||
"ConfigDescription": "Gmailメール連携のための拡張機能",
|
||||
"GooglePrivacy": "Huly による Google API から受信した情報の他のアプリへの使用および転送は、<a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>(限定的な使用の要件を含む)に準拠します。",
|
||||
"EmailNotificationsDescription": "メールで個人通知を受け取る"
|
||||
"EmailNotificationsDescription": "メールで個人通知を受け取る",
|
||||
"FailedToConnect": "Gmail サービスは利用できません",
|
||||
"TotalMessages": "メッセージの総数",
|
||||
"FailedToLoadState": "Gmail の状態の読み込みに失敗しました",
|
||||
"NotConnectedIntegration": "アカウント {email} はワークスペースに統合されていません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Extensão para a integração de email do Gmail",
|
||||
"GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API Services User Data Policy</a>, including the Limited Use requirements.",
|
||||
"EmailNotificationsDescription": "Receba notificações pessoais por e-mail.",
|
||||
"FailedToConnect": "Não foi possível conectar a conta do Gmail"
|
||||
"FailedToConnect": "O serviço Gmail não está disponível",
|
||||
"TotalMessages": "Total de mensagens",
|
||||
"FailedToLoadState": "Falha ao carregar o estado do Gmail",
|
||||
"NotConnectedIntegration": "A conta {email} não está integrada com o espaço de trabalho"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Расширение по работе с Gmail электронной почтой",
|
||||
"GooglePrivacy": "Использование и передача информации, полученной Huly от Google API, будет соответствовать <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Политике использования данных пользователей Google API</a>, включая требования ограниченного использования.",
|
||||
"EmailNotificationsDescription": "Получайте персональные уведомления на электронную почту.",
|
||||
"FailedToConnect": "Не удалось подключиться к Gmail"
|
||||
"FailedToConnect": "Gmail сервис недоступен",
|
||||
"TotalMessages": "Всего сообщений",
|
||||
"FailedToLoadState": "Не удалось загрузить состояние Gmail",
|
||||
"NotConnectedIntegration": "Учетная запись {email} не интегрирована с рабочим пространством"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@
|
||||
"ConfigDescription": "Gmail 邮件集成扩展",
|
||||
"GooglePrivacy": "Huly 从 Google API 接收的信息的使用和传输将遵守 <a href=\"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes\" target=\"_blank\">Google API 服务用户数据政策</a>,包括有限使用要求。",
|
||||
"EmailNotificationsDescription": "透過電子郵件接收個人通知。",
|
||||
"FailedToConnect": "無法連接 Gmail 帳戶。請檢查您的憑證並重試。"
|
||||
"FailedToConnect": "Gmail 服务不可用",
|
||||
"TotalMessages": "总消息数",
|
||||
"FailedToLoadState": "无法加载 Gmail 状态",
|
||||
"NotConnectedIntegration": "帐户 {email} 未与工作区集成"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
"@hcengineering/core": "^0.6.32",
|
||||
"@hcengineering/panel": "^0.6.23",
|
||||
"@hcengineering/analytics": "^0.6.0",
|
||||
"@hcengineering/templates": "^0.6.11"
|
||||
"@hcengineering/templates": "^0.6.11",
|
||||
"@hcengineering/integration-client": "^0.6.0",
|
||||
"@hcengineering/account-client": "^0.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import login from '@hcengineering/login'
|
||||
import { type GmailSyncState, gmailIntegrationKind } from '@hcengineering/gmail'
|
||||
import {
|
||||
getIntegrationClient as getIntegrationClientRaw,
|
||||
type IntegrationClient
|
||||
} from '@hcengineering/integration-client'
|
||||
|
||||
import gmail from './plugin'
|
||||
import { concatLink } from '@hcengineering/core'
|
||||
|
||||
export async function getIntegrationClient (): Promise<IntegrationClient> {
|
||||
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
|
||||
const token = getMetadata(presentation.metadata.Token)
|
||||
if (accountsUrl === undefined || token === undefined) {
|
||||
throw new Error('Accounts URL or token is not defined')
|
||||
}
|
||||
return getIntegrationClientRaw(accountsUrl, token, gmailIntegrationKind, 'gmail')
|
||||
}
|
||||
|
||||
const url = getMetadata(gmail.metadata.GmailURL) ?? ''
|
||||
|
||||
async function request (method: 'GET' | 'POST' | 'DELETE', path?: string, body?: any): Promise<any> {
|
||||
const response = await fetch(concatLink(url, path ?? ''), {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + getMetadata(presentation.metadata.Token),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
||||
})
|
||||
|
||||
if (response.status === 200) {
|
||||
return await response.json()
|
||||
} else if (response.status === 202) {
|
||||
return undefined
|
||||
} else {
|
||||
throw new Error(`Unexpected response: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getState (socialId: string): Promise<GmailSyncState | null> {
|
||||
return await request('GET', `/state?socialId=${encodeURIComponent(socialId)}`)
|
||||
}
|
||||
|
||||
export async function signout (): Promise<void> {
|
||||
await request('GET', '/signout')
|
||||
}
|
||||
@@ -13,29 +13,62 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { AccountArrayEditor } from '@hcengineering/contact-resources'
|
||||
import { AccountUuid } from '@hcengineering/core'
|
||||
import presentation, { Card, getClient } from '@hcengineering/presentation'
|
||||
import { Integration } from '@hcengineering/setting'
|
||||
import { Grid, Label, Toggle } from '@hcengineering/ui'
|
||||
import { getCurrentEmployee } from '@hcengineering/contact'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
import { AccountArrayEditor } from '@hcengineering/contact-resources'
|
||||
import { AccountUuid } from '@hcengineering/core'
|
||||
import presentation, { Card, getClient, getCurrentWorkspaceUuid } from '@hcengineering/presentation'
|
||||
import setting, { Integration } from '@hcengineering/setting'
|
||||
import { Grid, Label, Toggle } from '@hcengineering/ui'
|
||||
import { getCurrentEmployee } from '@hcengineering/contact'
|
||||
import { isWorkspaceIntegration } from '@hcengineering/integration-client'
|
||||
import { Integration as AccountIntegration } from '@hcengineering/account-client'
|
||||
|
||||
import { getIntegrationClient } from '../api'
|
||||
import gmail from '../plugin'
|
||||
|
||||
export let integration: Integration
|
||||
let shared = (integration.shared?.length ?? 0) > 0
|
||||
export let integration: AccountIntegration
|
||||
let integrationSettings: Integration | undefined
|
||||
let shared = false
|
||||
|
||||
const currentEmployee = getCurrentEmployee()
|
||||
const client = getClient()
|
||||
|
||||
$: loadSettings(integration)
|
||||
|
||||
async function loadSettings (integration: AccountIntegration): Promise<void> {
|
||||
const type = await client.findOne(setting.class.IntegrationType, {
|
||||
kind: integration.kind
|
||||
})
|
||||
integrationSettings = await client.findOne(setting.class.Integration, {
|
||||
createdBy: integration.socialId,
|
||||
type: type?._id
|
||||
})
|
||||
shared = (integrationSettings?.shared?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
async function change (shared: AccountUuid[]) {
|
||||
integration.shared = shared
|
||||
await client.update(integration, {
|
||||
const integrationClient = await getIntegrationClient()
|
||||
integration = isWorkspaceIntegration(integration)
|
||||
? integration
|
||||
: await integrationClient.integrate(integration, getCurrentWorkspaceUuid())
|
||||
if (integrationSettings == null) {
|
||||
console.error('Integrations settings are not found', integration.socialId, integration.kind)
|
||||
return
|
||||
}
|
||||
integrationSettings.shared = shared
|
||||
await client.update(integrationSettings, {
|
||||
shared
|
||||
})
|
||||
}
|
||||
|
||||
async function apply () {
|
||||
const integrationClient = await getIntegrationClient()
|
||||
integration = isWorkspaceIntegration(integration)
|
||||
? integration
|
||||
: await integrationClient.integrate(integration, getCurrentWorkspaceUuid())
|
||||
}
|
||||
|
||||
async function disable () {
|
||||
if (!shared) {
|
||||
await change([])
|
||||
@@ -47,7 +80,8 @@
|
||||
|
||||
<Card
|
||||
label={gmail.string.Shared}
|
||||
okAction={() => {
|
||||
okAction={async () => {
|
||||
await apply()
|
||||
dispatch('close')
|
||||
}}
|
||||
canSave={true}
|
||||
@@ -70,7 +104,7 @@
|
||||
kind={'regular'}
|
||||
label={gmail.string.AvailableTo}
|
||||
excludeItems={[currentEmployee]}
|
||||
value={integration.shared ?? []}
|
||||
value={integrationSettings?.shared ?? []}
|
||||
onChange={(res) => change(res)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
|
||||
import { IconError, Label, Loading } from '@hcengineering/ui'
|
||||
import type { Integration } from '@hcengineering/account-client'
|
||||
import { IntegrationEventData, onIntegrationEvent } from '@hcengineering/integration-client'
|
||||
import { type GmailSyncState } from '@hcengineering/gmail'
|
||||
|
||||
import gmail from '../plugin'
|
||||
import { getState } from '../api'
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
|
||||
export let integration: Integration
|
||||
|
||||
let isLoading = true
|
||||
let isRefreshing = false
|
||||
let error: string | null = null
|
||||
let errorLabel: IntlString | undefined
|
||||
let state: GmailSyncState | null | undefined
|
||||
|
||||
const unsubscribers: (() => void)[] = []
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
state = await getState(integration.socialId)
|
||||
isLoading = false
|
||||
subscribe()
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load gmail state'
|
||||
if (error.includes('Failed to fetch')) {
|
||||
errorLabel = gmail.string.FailedToConnect
|
||||
}
|
||||
isLoading = false
|
||||
console.error('Error loading gmail state:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribers.forEach((unsubscribe) => {
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
function subscribe (): void {
|
||||
unsubscribers.push(onIntegrationEvent<IntegrationEventData>('integration:updated', onUpdateIntegration))
|
||||
}
|
||||
|
||||
function onUpdateIntegration (data: IntegrationEventData): void {
|
||||
if (
|
||||
data.integration?.socialId === integration.socialId &&
|
||||
data.integration?.workspaceUuid === integration.workspaceUuid
|
||||
) {
|
||||
void refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh (): Promise<void> {
|
||||
try {
|
||||
isRefreshing = true
|
||||
state = await getState(integration.socialId)
|
||||
} catch (err: any) {
|
||||
console.error('Error refresh gmail state:', err.message)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="integration-state">
|
||||
<div class="state-content">
|
||||
{#if isLoading}
|
||||
<div class="loading-container">
|
||||
<Loading />
|
||||
</div>
|
||||
{:else if integration.workspaceUuid == null}
|
||||
<div class="flex-center" transition:fade={{ duration: 300 }}>
|
||||
<span class="text-normal content-color">
|
||||
<Label label={gmail.string.NotConnectedIntegration} params={{ email: state?.email ?? '' }} />
|
||||
</span>
|
||||
</div>
|
||||
{:else if errorLabel !== undefined}
|
||||
<div class="error-container" transition:fade={{ duration: 300 }}>
|
||||
<IconError size={'medium'} />
|
||||
<Label label={errorLabel} />
|
||||
</div>
|
||||
{:else if error != null}
|
||||
<div class="error-container" transition:fade={{ duration: 300 }}>
|
||||
<IconError size={'medium'} />
|
||||
<Label label={gmail.string.FailedToLoadState} />
|
||||
</div>
|
||||
{:else if state != null}
|
||||
<div class="stats-list" transition:fade={{ duration: 300 }}>
|
||||
<div class="stat-row">
|
||||
<span class="text-normal content-color"><Label label={gmail.string.Email} /></span>
|
||||
<span class="text-normal content-color font-medium">{state.email ?? 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="text-normal content-color"><Label label={gmail.string.TotalMessages} /></span>
|
||||
<div class="flex justify-between flex-gap-2">
|
||||
{#if isRefreshing}
|
||||
<Loading size="small" />
|
||||
{/if}
|
||||
<span class="text-normal content-color font-medium">{state.totalMessages}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.integration-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.state-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -17,12 +17,12 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 36 36"
|
||||
viewBox="3 3 30 30"
|
||||
style="enable-background:new 0 0 36 36;"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<path
|
||||
fill={'#FFFFFF'}
|
||||
fill={'none'}
|
||||
d="M0,12.8c0-4.5,0-6.7,0.9-8.4c0.8-1.5,2-2.7,3.5-3.5C6.1,0,8.3,0,12.8,0h10.4c4.5,0,6.7,0,8.4,0.9 c1.5,0.8,2.7,2,3.5,3.5c0.9,1.7,0.9,4,0.9,8.4v10.4c0,4.5,0,6.7-0.9,8.4c-0.8,1.5-2,2.7-3.5,3.5c-1.7,0.9-4,0.9-8.4,0.9H12.8 c-4.5,0-6.7,0-8.4-0.9c-1.5-0.8-2.7-2-3.5-3.5C0,29.9,0,27.7,0,23.2V12.8z"
|
||||
/>
|
||||
<path fill={'#EA4335'} d="M25.8,8.4l-7.7,6l-7.9-6v0l0,0v8.4l7.8,6.1l7.8-5.9V8.4z" />
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -14,7 +14,6 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { concatLink } from '@hcengineering/core'
|
||||
import { getMetadata, type Resources } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import GmailWriteMessage from './components/activity/GmailWriteMessage.svelte'
|
||||
@@ -24,8 +23,11 @@ import Connect from './components/Connect.svelte'
|
||||
import IconGmail from './components/icons/GmailColor.svelte'
|
||||
import Main from './components/Main.svelte'
|
||||
import NewMessages from './components/NewMessages.svelte'
|
||||
import IntegrationState from './components/IntegrationState.svelte'
|
||||
import gmail from '@hcengineering/gmail'
|
||||
import { checkHasEmail, MessageTitleProvider } from './utils'
|
||||
import { getIntegrationClient, signout } from './api'
|
||||
import type { Integration } from '@hcengineering/account-client'
|
||||
|
||||
export default async (): Promise<Resources> => ({
|
||||
component: {
|
||||
@@ -33,7 +35,8 @@ export default async (): Promise<Resources> => ({
|
||||
Connect,
|
||||
IconGmail,
|
||||
NewMessages,
|
||||
Configure
|
||||
Configure,
|
||||
IntegrationState
|
||||
},
|
||||
activity: {
|
||||
GmailWriteMessage,
|
||||
@@ -44,17 +47,35 @@ export default async (): Promise<Resources> => ({
|
||||
MessageTitleProvider
|
||||
},
|
||||
handler: {
|
||||
DisconnectHandler: async () => {
|
||||
DisconnectHandler: async (integration: Integration): Promise<void> => {
|
||||
const url = getMetadata(gmail.metadata.GmailURL)
|
||||
const token = getMetadata(presentation.metadata.Token)
|
||||
if (url === undefined || token === undefined) return
|
||||
await fetch(concatLink(url, '/signout'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
if (url === undefined || token === undefined) {
|
||||
console.error('Url or token is not defined in DisconnectHandler')
|
||||
}
|
||||
if (integration == null) {
|
||||
console.error('Missing required argument integration in DisconnectHandler')
|
||||
return
|
||||
}
|
||||
const integrationClient = await getIntegrationClient()
|
||||
const result = await integrationClient.removeIntegration(integration.socialId, integration.workspaceUuid)
|
||||
if (result !== undefined && result.connectionRemoved) {
|
||||
await signout()
|
||||
}
|
||||
},
|
||||
DisconnectAllHandler: async (integration: Integration): Promise<void> => {
|
||||
const url = getMetadata(gmail.metadata.GmailURL)
|
||||
const token = getMetadata(presentation.metadata.Token)
|
||||
if (url === undefined || token === undefined) {
|
||||
console.error('Url or token is not defined in DisconnectHandler')
|
||||
}
|
||||
if (integration == null) {
|
||||
console.error('Missing required argument integration in DisconnectHandler')
|
||||
return
|
||||
}
|
||||
const integrationClient = await getIntegrationClient()
|
||||
await integrationClient.removeConnection(integration)
|
||||
await signout()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -44,6 +44,9 @@ export default mergeIds(gmailId, gmail, {
|
||||
HaveWrittenEmail: '' as IntlString,
|
||||
NewIncomingMessage: '' as IntlString,
|
||||
GooglePrivacy: '' as IntlString,
|
||||
FailedToConnect: '' as IntlString
|
||||
FailedToConnect: '' as IntlString,
|
||||
FailedToLoadState: '' as IntlString,
|
||||
NotConnectedIntegration: '' as IntlString,
|
||||
TotalMessages: '' as IntlString
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//
|
||||
|
||||
import { ChannelItem } from '@hcengineering/contact'
|
||||
import type { PersonId, AttachedDoc, Class, Doc, Ref, Timestamp } from '@hcengineering/core'
|
||||
import type { PersonId, AttachedDoc, Class, Doc, Ref, Timestamp, IntegrationKind } from '@hcengineering/core'
|
||||
import { NotificationProvider, NotificationType } from '@hcengineering/notification'
|
||||
import type { IntlString, Metadata, Plugin } from '@hcengineering/platform'
|
||||
import { plugin } from '@hcengineering/platform'
|
||||
@@ -76,6 +76,22 @@ export interface SharedMessages extends AttachedDoc {
|
||||
messages: SharedMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const gmailIntegrationKind = 'gmail' as IntegrationKind
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GmailSyncState {
|
||||
status: string
|
||||
email: string
|
||||
totalMessages: number | null | undefined
|
||||
lastSynchronization?: Date | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -87,7 +103,8 @@ export default plugin(gmailId, {
|
||||
Connect: '' as AnyComponent,
|
||||
IconGmail: '' as AnyComponent,
|
||||
NewMessages: '' as AnyComponent,
|
||||
Configure: '' as AnyComponent
|
||||
Configure: '' as AnyComponent,
|
||||
IntegrationState: '' as AnyComponent
|
||||
},
|
||||
string: {
|
||||
From: '' as IntlString,
|
||||
@@ -98,7 +115,8 @@ export default plugin(gmailId, {
|
||||
Gmail: '' as Ref<IntegrationType>
|
||||
},
|
||||
handler: {
|
||||
DisconnectHandler: '' as Handler
|
||||
DisconnectHandler: '' as Handler,
|
||||
DisconnectAllHandler: '' as Handler
|
||||
},
|
||||
class: {
|
||||
Message: '' as Ref<Class<Message>>,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "Aktuální heslo",
|
||||
"NewPassword": "Nové heslo",
|
||||
"Disconnect": "Odpojit",
|
||||
"DisconnectAll": "Odpojit všechny",
|
||||
"Saving": "Ukládání...",
|
||||
"Saved": "Uloženo",
|
||||
"Add": "Přidat",
|
||||
@@ -178,6 +179,18 @@
|
||||
"ReleaseSocialId": "Uvolnit sociální ID",
|
||||
"ReleaseSocialIdConfirm": "Opravdu chcete uvolnit toto sociální ID: {socialId}? Tím ho odeberete ze svého účtu a nebudete ho již moci používat k přihlášení. Také budou odstraněny všechny související integrace.",
|
||||
"ReleasePrimarySocialId": "Uvolnit primární sociální ID",
|
||||
"ReleasePrimarySocialIdConfirm": "Uvolnění vašeho současného primárního sociálního ID bude vyžadovat obnovení stránky. Opravdu chcete pokračovat?"
|
||||
"ReleasePrimarySocialIdConfirm": "Uvolnění vašeho současného primárního sociálního ID bude vyžadovat obnovení stránky. Opravdu chcete pokračovat?",
|
||||
"MyIntegrations": "Moje integrace",
|
||||
"AllIntegrations": "Všechny",
|
||||
"ConnectedIntegrations": "Připojené",
|
||||
"AvailableIntegrations": "Dostupné",
|
||||
"Connect": "Připojit",
|
||||
"Integrate": "Integrace",
|
||||
"FailedToLoadIntegrations": "Nepodařilo se načíst integrace",
|
||||
"FailedToDisconnect": "Nepodařilo se odpojit integraci",
|
||||
"ServiceIsUnavailable": "Služba není k dispozici",
|
||||
"Integrated": "Integrované",
|
||||
"Connected": "Připojené",
|
||||
"Available": "Dostupné"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "Aktuelles Passwort",
|
||||
"NewPassword": "Neues Passwort",
|
||||
"Disconnect": "Trennen",
|
||||
"DisconnectAll": "Alle trennen",
|
||||
"Saving": "Wird gespeichert...",
|
||||
"Saved": "Gespeichert",
|
||||
"Add": "Hinzufügen",
|
||||
@@ -180,6 +181,18 @@
|
||||
"ReleasePrimarySocialId": "Primäre Social ID freigeben",
|
||||
"ReleasePrimarySocialIdConfirm": "Die Freigabe Ihrer aktuellen primären Social ID erfordert ein Neuladen der Seite. Sind Sie sicher, dass Sie fortfahren möchten?",
|
||||
"Login": "Anmelden",
|
||||
"Primary": "Primär"
|
||||
"Primary": "Primär",
|
||||
"MyIntegrations": "Meine Integrationen",
|
||||
"AllIntegrations": "Alle",
|
||||
"ConnectedIntegrations": "Verbunden",
|
||||
"AvailableIntegrations": "Verfügbar",
|
||||
"Connect": "Verbinden",
|
||||
"Integrate": "Integrieren",
|
||||
"FailedToLoadIntegrations": "Fehler beim Laden der Integrationen",
|
||||
"FailedToDisconnect": "Fehler beim Trennen der Integration",
|
||||
"ServiceIsUnavailable": "Dienst ist nicht verfügbar",
|
||||
"Integrated": "Integriert",
|
||||
"Connected": "Verbunden",
|
||||
"Available": "Verfügbar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "Current password",
|
||||
"NewPassword": "New password",
|
||||
"Disconnect": "Disconnect",
|
||||
"DisconnectAll": "Disconnect all",
|
||||
"Saving": "Saving...",
|
||||
"Saved": "Saved",
|
||||
"Add": "Add",
|
||||
@@ -180,6 +181,18 @@
|
||||
"ReleasePrimarySocialId": "Release primary social ID",
|
||||
"ReleasePrimarySocialIdConfirm": "Releasing your current primary social id will require the page to be reloaded. Are you sure you want to proceed?",
|
||||
"Login": "Login",
|
||||
"Primary": "Primary"
|
||||
"Primary": "Primary",
|
||||
"MyIntegrations": "My integrations",
|
||||
"AllIntegrations": "All",
|
||||
"ConnectedIntegrations": "Integrated",
|
||||
"AvailableIntegrations": "Available",
|
||||
"Connect": "Connect",
|
||||
"Integrate": "Integrate",
|
||||
"FailedToLoadIntegrations": "Failed to load integrations",
|
||||
"FailedToDisconnect": "Failed to disconnect integration",
|
||||
"ServiceIsUnavailable": "Service is unavailable",
|
||||
"Integrated": "Integrated",
|
||||
"Connected": "Connected",
|
||||
"Available": "Available"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "Contraseña actual",
|
||||
"NewPassword": "Nueva contraseña",
|
||||
"Disconnect": "Desconectar",
|
||||
"DisconnectAll": "Desconectar todos",
|
||||
"Saving": "Guardando...",
|
||||
"Saved": "Guardado",
|
||||
"Add": "Añadir",
|
||||
@@ -171,6 +172,18 @@
|
||||
"ReleasePrimarySocialId": "Liberar ID social primario",
|
||||
"ReleasePrimarySocialIdConfirm": "Liberar tu ID social primario actual requerirá que se recargue la página. ¿Estás seguro de que quieres continuar?",
|
||||
"Login": "Iniciar sesión",
|
||||
"Primary": "Primario"
|
||||
"Primary": "Primario",
|
||||
"MyIntegrations": "Mis integraciones",
|
||||
"AllIntegrations": "Todas",
|
||||
"ConnectedIntegrations": "Conectadas",
|
||||
"AvailableIntegrations": "Disponibles",
|
||||
"Connect": "Conectar",
|
||||
"Integrate": "Integrar",
|
||||
"FailedToLoadIntegrations": "Error al cargar las integraciones",
|
||||
"FailedToDisconnect": "Error al desconectar la integración",
|
||||
"ServiceIsUnavailable": "El servicio no está disponible",
|
||||
"Integrated": "Integrado",
|
||||
"Connected": "Conectado",
|
||||
"Available": "Disponible"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "Mot de passe actuel",
|
||||
"NewPassword": "Nouveau mot de passe",
|
||||
"Disconnect": "Déconnecter",
|
||||
"DisconnectAll": "Déconnecter tous",
|
||||
"Saving": "Enregistrement...",
|
||||
"Saved": "Enregistré",
|
||||
"Add": "Ajouter",
|
||||
@@ -180,6 +181,18 @@
|
||||
"ReleasePrimarySocialId": "Libérer l'ID social principal",
|
||||
"ReleasePrimarySocialIdConfirm": "Libérer votre ID social principal actuel nécessitera de recharger la page. Êtes-vous sûr de vouloir continuer ?",
|
||||
"Login": "Connexion",
|
||||
"Primary": "Principal"
|
||||
"Primary": "Principal",
|
||||
"MyIntegrations": "Mes intégrations",
|
||||
"AllIntegrations": "Toutes",
|
||||
"ConnectedIntegrations": "Connectées",
|
||||
"AvailableIntegrations": "Disponibles",
|
||||
"Connect": "Connecter",
|
||||
"Integrate": "Intégrer",
|
||||
"FailedToLoadIntegrations": "Échec du chargement des intégrations",
|
||||
"FailedToDisconnect": "Échec de la déconnexion de l'intégration",
|
||||
"ServiceIsUnavailable": "Le service n'est pas disponible",
|
||||
"Integrated": "Intégré",
|
||||
"Connected": "Connecté",
|
||||
"Available": "Disponible"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "Password attuale",
|
||||
"NewPassword": "Nuova password",
|
||||
"Disconnect": "Disconnetti",
|
||||
"DisconnectAll": "Disconnetti tutti",
|
||||
"Saving": "Salvataggio...",
|
||||
"Saved": "Salvato",
|
||||
"Add": "Aggiungi",
|
||||
@@ -180,6 +181,18 @@
|
||||
"ReleasePrimarySocialId": "Rilascia ID social primario",
|
||||
"ReleasePrimarySocialIdConfirm": "Rilasciare il tuo ID social primario attuale richiederà il ricaricamento della pagina. Sei sicuro di voler procedere?",
|
||||
"Login": "Accesso",
|
||||
"Primary": "Primario"
|
||||
"Primary": "Primario",
|
||||
"MyIntegrations": "Le mie integrazioni",
|
||||
"AllIntegrations": "Tutte",
|
||||
"ConnectedIntegrations": "Connesse",
|
||||
"AvailableIntegrations": "Disponibili",
|
||||
"Connect": "Connetti",
|
||||
"Integrate": "Integra",
|
||||
"FailedToLoadIntegrations": "Impossibile caricare le integrazioni",
|
||||
"FailedToDisconnect": "Impossibile disconnettere l'integrazione",
|
||||
"ServiceIsUnavailable": "Il servizio non è disponibile",
|
||||
"Integrated": "Integrato",
|
||||
"Connected": "Connesso",
|
||||
"Available": "Disponibile"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "現在のパスワード",
|
||||
"NewPassword": "新しいパスワード",
|
||||
"Disconnect": "切断",
|
||||
"DisconnectAll": "すべて切断",
|
||||
"Saving": "保存中...",
|
||||
"Saved": "保存済み",
|
||||
"Add": "追加",
|
||||
@@ -180,6 +181,18 @@
|
||||
"ReleasePrimarySocialId": "プライマリソーシャルIDをリリース",
|
||||
"ReleasePrimarySocialIdConfirm": "現在のプライマリソーシャルIDをリリースするには、ページの再読み込みが必要です。続行してもよろしいですか?",
|
||||
"Login": "ログイン",
|
||||
"Primary": "プライマリ"
|
||||
"Primary": "プライマリ",
|
||||
"MyIntegrations": "マイインテグレーション",
|
||||
"AllIntegrations": "すべてのインテグレーション",
|
||||
"ConnectedIntegrations": "接続されたインテグレーション",
|
||||
"AvailableIntegrations": "利用可能なインテグレーション",
|
||||
"Connect": "接続",
|
||||
"Integrate": "統合",
|
||||
"FailedToLoadIntegrations": "インテグレーションの読み込みに失敗しました",
|
||||
"FailedToDisconnect": "インテグレーションの切断に失敗しました",
|
||||
"ServiceIsUnavailable": "サービスは利用できません",
|
||||
"Integrated": "統合済み",
|
||||
"Connected": "接続済み",
|
||||
"Available": "利用可能"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "Senha atual",
|
||||
"NewPassword": "Nova senha",
|
||||
"Disconnect": "Desconectar",
|
||||
"DisconnectAll": "Desconectar todos",
|
||||
"Saving": "Salvando...",
|
||||
"Saved": "Salvo",
|
||||
"Add": "Adicionar",
|
||||
@@ -171,6 +172,18 @@
|
||||
"ReleasePrimarySocialId": "Liberar ID social primário",
|
||||
"ReleasePrimarySocialIdConfirm": "Liberar seu ID social primário atual exigirá que a página seja recarregada. Tem certeza de que deseja prosseguir?",
|
||||
"Login": "Login",
|
||||
"Primary": "Primário"
|
||||
"Primary": "Primário",
|
||||
"MyIntegrations": "Minhas integrações",
|
||||
"AllIntegrations": "Todas",
|
||||
"ConnectedIntegrations": "Conectadas",
|
||||
"AvailableIntegrations": "Disponíveis",
|
||||
"Connect": "Conectar",
|
||||
"Integrate": "Integrar",
|
||||
"FailedToLoadIntegrations": "Falha ao carregar integrações",
|
||||
"FailedToDisconnect": "Falha ao desconectar a integração",
|
||||
"ServiceIsUnavailable": "Serviço indisponível",
|
||||
"Integrated": "Integrado",
|
||||
"Connected": "Conectado",
|
||||
"Available": "Disponível"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "Текущий пароль",
|
||||
"NewPassword": "Новый пароль",
|
||||
"Disconnect": "Отключить",
|
||||
"DisconnectAll": "Отключить все",
|
||||
"Saving": "Сохранение...",
|
||||
"Saved": "Сохранено",
|
||||
"Add": "Добавить",
|
||||
@@ -180,6 +181,18 @@
|
||||
"ReleasePrimarySocialId": "Освободить основной социальный ID",
|
||||
"ReleasePrimarySocialIdConfirm": "Освобождение вашего текущего основного социального ID потребует перезагрузки страницы. Вы уверены, что хотите продолжить?",
|
||||
"Login": "Вход",
|
||||
"Primary": "Основной"
|
||||
"Primary": "Основной",
|
||||
"MyIntegrations": "Мои интеграции",
|
||||
"AllIntegrations": "Все",
|
||||
"ConnectedIntegrations": "Подключенные",
|
||||
"AvailableIntegrations": "Доступные",
|
||||
"Connect": "Подключить",
|
||||
"Integrate": "Интегрировать",
|
||||
"FailedToLoadIntegrations": "Не удалось загрузить интеграции",
|
||||
"FailedToDisconnect": "Не удалось отключить интеграцию",
|
||||
"ServiceIsUnavailable": "Сервис недоступен",
|
||||
"Integrated": "Интегрировано",
|
||||
"Connected": "Подключено",
|
||||
"Available": "Доступно"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"CurrentPassword": "当前密码",
|
||||
"NewPassword": "新密码",
|
||||
"Disconnect": "断开连接",
|
||||
"DisconnectAll": "断开所有连接",
|
||||
"Saving": "保存中...",
|
||||
"Saved": "已保存",
|
||||
"Add": "添加",
|
||||
@@ -180,6 +181,18 @@
|
||||
"ReleasePrimarySocialId": "释放主要社交ID",
|
||||
"ReleasePrimarySocialIdConfirm": "释放您当前的主要社交ID需要重新加载页面。您确定要继续吗?",
|
||||
"Login": "登录",
|
||||
"Primary": "主要"
|
||||
"Primary": "主要",
|
||||
"MyIntegrations": "我的集成",
|
||||
"AllIntegrations": "所有集成",
|
||||
"ConnectedIntegrations": "已连接的集成",
|
||||
"AvailableIntegrations": "可用的集成 ",
|
||||
"Connect": "连接",
|
||||
"Integrate": "集成",
|
||||
"FailedToLoadIntegrations": "加载集成失败",
|
||||
"FailedToDisconnect": "断开集成失败",
|
||||
"ServiceIsUnavailable": "服务不可用",
|
||||
"Integrated": "已集成",
|
||||
"Connected": "已连接",
|
||||
"Available": "可用"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@hcengineering/workbench": "^0.6.16",
|
||||
"@hcengineering/workbench-resources": "^0.6.1",
|
||||
"@hcengineering/communication": "^0.6.0",
|
||||
"@hcengineering/chat": "^0.6.0"
|
||||
"@hcengineering/chat": "^0.6.0",
|
||||
"@hcengineering/integration-client": "^0.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<!--
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { Panel } from '@hcengineering/panel'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import { Integration, IntegrationType } from '@hcengineering/setting'
|
||||
import setting from '../plugin'
|
||||
import PluginCard from './PluginCard.svelte'
|
||||
import { translate } from '@hcengineering/platform'
|
||||
import { themeStore } from '@hcengineering/ui'
|
||||
|
||||
export let _id: Ref<Integration>
|
||||
// export let _class: Ref<Class<Integration>>
|
||||
export let embedded: boolean = false
|
||||
|
||||
let integration: Integration | undefined = undefined
|
||||
const query = createQuery()
|
||||
|
||||
$: query.query(
|
||||
setting.class.Integration,
|
||||
{
|
||||
_id
|
||||
},
|
||||
(res) => {
|
||||
;[integration] = res
|
||||
}
|
||||
)
|
||||
|
||||
let type: IntegrationType | undefined = undefined
|
||||
const typeQuery = createQuery()
|
||||
|
||||
$: integration &&
|
||||
typeQuery.query(
|
||||
setting.class.IntegrationType,
|
||||
{
|
||||
_id: integration.type
|
||||
},
|
||||
(res) => {
|
||||
;[type] = res
|
||||
}
|
||||
)
|
||||
|
||||
let title: string = ''
|
||||
translate(setting.string.Integrations, {}, $themeStore.language).then((res) => (title = res))
|
||||
</script>
|
||||
|
||||
{#if integration}
|
||||
<Panel {title} object={integration} isHeader={false} isAside={false} {embedded} withoutActivity withoutInput>
|
||||
<div class="max-w-80 min-w-80">
|
||||
{#if type}
|
||||
<PluginCard {integration} integrationType={type} />
|
||||
{/if}
|
||||
</div>
|
||||
</Panel>
|
||||
{/if}
|
||||
@@ -1,105 +0,0 @@
|
||||
<!--
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Ref, getCurrentAccount } from '@hcengineering/core'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import { type Integration, type IntegrationType, IntegrationError } from '@hcengineering/setting'
|
||||
import setting from '@hcengineering/setting'
|
||||
|
||||
import { Header, Breadcrumb, NotificationSeverity, addNotification, themeStore } from '@hcengineering/ui'
|
||||
import { translate } from '@hcengineering/platform'
|
||||
import PluginCard from './PluginCard.svelte'
|
||||
import IntegrationErrorNotification from './IntegrationErrorNotification.svelte'
|
||||
|
||||
const typeQuery = createQuery()
|
||||
const integrationQuery = createQuery()
|
||||
|
||||
let integrations: Integration[] = []
|
||||
let integrationTypes: IntegrationType[] = []
|
||||
|
||||
typeQuery.query(setting.class.IntegrationType, {}, (res) => {
|
||||
integrationTypes = res
|
||||
})
|
||||
integrationQuery.query(setting.class.Integration, { createdBy: { $in: getCurrentAccount().socialIds } }, (res) => {
|
||||
integrations = res.filter((p) => p.value !== '')
|
||||
})
|
||||
|
||||
function getIntegrations (type: Ref<IntegrationType>, integrations: Integration[]): Integration[] {
|
||||
return integrations.filter((p) => p.type === type)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Check URL parameters for error message
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const error = urlParams.get('integrationError')
|
||||
|
||||
if (error != null) {
|
||||
const decodedError = decodeURIComponent(error)
|
||||
console.error('Integration error:', decodedError)
|
||||
await showErrorNotification(decodedError)
|
||||
// Clean up integrationError parameter from the URL
|
||||
urlParams.delete('integrationError')
|
||||
const newParams = urlParams.toString()
|
||||
const newUrl =
|
||||
window.location.pathname + (newParams != null && newParams !== '' ? `?${newParams}` : '') + window.location.hash
|
||||
window.history.replaceState({}, document.title, newUrl)
|
||||
}
|
||||
})
|
||||
|
||||
async function showErrorNotification (error: string): Promise<void> {
|
||||
const errorMessage =
|
||||
error === IntegrationError.EMAIL_IS_ALREADY_USED
|
||||
? await translate(setting.string.EmailIsUsed, {}, $themeStore.language)
|
||||
: await translate(setting.string.IntegrationError, {}, $themeStore.language)
|
||||
addNotification(
|
||||
await translate(setting.string.IntegrationFailed, {}, $themeStore.language),
|
||||
errorMessage,
|
||||
IntegrationErrorNotification,
|
||||
undefined,
|
||||
NotificationSeverity.Error
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="hulyComponent">
|
||||
<Header adaptive={'disabled'}>
|
||||
<Breadcrumb icon={setting.icon.Integrations} label={setting.string.Integrations} size={'large'} isCurrent />
|
||||
</Header>
|
||||
|
||||
<div class="cards_grid">
|
||||
{#each integrationTypes as integrationType (integrationType._id)}
|
||||
{#if integrationType.allowMultiple}
|
||||
{#each getIntegrations(integrationType._id, integrations) as integration (integration._id)}
|
||||
<PluginCard {integration} {integrationType} />
|
||||
{/each}
|
||||
<PluginCard integration={undefined} {integrationType} />
|
||||
{:else}
|
||||
<PluginCard integration={integrations.find((p) => p.type === integrationType._id)} {integrationType} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.cards_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(20rem, auto));
|
||||
grid-auto-rows: minmax(12.5rem, auto);
|
||||
grid-gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user