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:
Artyom Savchenko
2025-08-04 15:14:06 +07:00
committed by GitHub
parent 39c40093e0
commit 7e92c12073
175 changed files with 6391 additions and 875 deletions
+39 -2
View File
@@ -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
View File
@@ -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)
+9 -6
View File
@@ -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 -1
View File
@@ -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 (
+4 -2
View File
@@ -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
)
+5 -1
View File
@@ -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
)
+12 -3
View File
@@ -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
)
+4 -5
View File
@@ -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,
-1
View File
@@ -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
},
+12 -7
View File
@@ -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
)
+5 -3
View File
@@ -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
+5
View File
@@ -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 }
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+4
View File
@@ -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()
+62
View File
@@ -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)
})
})
})
+340
View File
@@ -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)
}
}
}
+125
View File
@@ -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)
}
})
}
+19
View File
@@ -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'
+301
View File
@@ -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'
}
}
+32
View File
@@ -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
)
}
+12
View File
@@ -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"]
}
+14 -7
View File
@@ -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++
+10 -6
View File
@@ -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, {
+2 -2
View File
@@ -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,
+24
View File
@@ -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)
}
+3 -1
View File
@@ -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"
}
}
+3 -1
View File
@@ -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"
}
}
+3 -1
View File
@@ -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"
}
}
+3 -1
View File
@@ -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"
}
}
+3 -1
View File
@@ -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"
}
}
+3 -1
View File
@@ -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"
}
}
+3 -1
View File
@@ -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": "クリア"
}
}
+3 -1
View File
@@ -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"
}
}
+3 -1
View File
@@ -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": "Очистить"
}
}
+3 -1
View File
@@ -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": "清除"
}
}
+1 -1
View File
@@ -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>
+2
View File
@@ -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'
+3
View File
@@ -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: {
+18
View File
@@ -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
}
+6 -1
View File
@@ -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
+5 -1
View File
@@ -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"
}
}
+5 -1
View File
@@ -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"
}
}
+6 -2
View File
@@ -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"
}
}
+5 -1
View File
@@ -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"
}
}
+5 -1
View File
@@ -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"
}
}
+5 -1
View File
@@ -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"
}
}
+5 -1
View File
@@ -117,6 +117,10 @@
"NextToLast": "最後から2番目",
"Last": "最後",
"Weekday": "平日",
"WeekendDay": "休日"
"WeekendDay": "休日",
"NotConnectedIntegration": "アカウント {email} はワークスペースに統合されていません",
"SynchronizedCalendars": "同期されたカレンダー",
"Account": "アカウント",
"NoCalendars": "同期するカレンダーが選択されていません"
}
}
+5 -1
View File
@@ -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"
}
}
+5 -1
View File
@@ -121,6 +121,10 @@
"NextToLast": "Предпоследний",
"Last": "Последний",
"Weekday": "Будний день",
"WeekendDay": "Выходной день"
"WeekendDay": "Выходной день",
"NotConnectedIntegration": "Учетная запись {email} не интегрирована с рабочим пространством",
"SynchronizedCalendars": "Синхронизированные календари",
"Account": "Аккаунт",
"NoCalendars": "Календари для синхронизации не выбраны"
}
}
+5 -1
View File
@@ -121,6 +121,10 @@
"NextToLast": "倒数第二个",
"Last": "最后一个",
"Weekday": "工作日",
"WeekendDay": "周末日"
"WeekendDay": "周末日",
"NotConnectedIntegration": "帐户 {email} 未与工作区集成",
"SynchronizedCalendars": "已同步的日历",
"Account": "帐户",
"NoCalendars": "未选择同步日历"
}
}
+1
View File
@@ -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"
+69
View File
@@ -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

+13 -15
View File
@@ -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: {
+20 -3
View File
@@ -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>
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+5 -2
View File
@@ -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": "Hulys 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"
}
}
+4 -1
View File
@@ -39,6 +39,9 @@
"ConfigDescription": "Extensión para la integración de correo electrónico de Gmail",
"GooglePrivacy": "Hulys 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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+5 -1
View File
@@ -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} はワークスペースに統合されていません"
}
}
+4 -1
View File
@@ -39,6 +39,9 @@
"ConfigDescription": "Extensão para a integração de email do Gmail",
"GooglePrivacy": "Hulys 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"
}
}
+4 -1
View File
@@ -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} не интегрирована с рабочим пространством"
}
}
+4 -1
View File
@@ -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} 未与工作区集成"
}
}
+3 -1
View File
@@ -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"
}
}
+63
View File
@@ -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

+32 -11
View File
@@ -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()
}
}
})
+4 -1
View File
@@ -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
}
})
+21 -3
View File
@@ -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>>,
+14 -1
View File
@@ -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é"
}
}
+14 -1
View File
@@ -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"
}
}
+14 -1
View File
@@ -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"
}
}
+14 -1
View File
@@ -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"
}
}
+14 -1
View File
@@ -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"
}
}
+14 -1
View File
@@ -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"
}
}
+14 -1
View File
@@ -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": "利用可能"
}
}
+14 -1
View File
@@ -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"
}
}
+14 -1
View File
@@ -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": "Доступно"
}
}
+14 -1
View File
@@ -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": "可用"
}
}
+2 -1
View File
@@ -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