mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Generated
+3
@@ -18563,6 +18563,9 @@ importers:
|
||||
'@hcengineering/core':
|
||||
specifier: workspace:^0.7.26
|
||||
version: link:../../foundations/core/packages/core
|
||||
'@hcengineering/emoji-resources':
|
||||
specifier: workspace:^0.7.0
|
||||
version: link:../emoji-resources
|
||||
'@hcengineering/image-cropper':
|
||||
specifier: workspace:^0.7.0
|
||||
version: link:../image-cropper
|
||||
|
||||
@@ -593,7 +593,7 @@ export interface ModulePermissionGroup extends Doc {
|
||||
role: AccountRole
|
||||
permissions: Ref<Permission>[]
|
||||
disabledPermissions?: Ref<Permission>[]
|
||||
spaceClass: Ref<Class<Space>>
|
||||
spaceClass?: Ref<Class<Space>>
|
||||
enabled: boolean
|
||||
order?: number
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function createModel (builder: Builder): void {
|
||||
application: chunter.app.Chunter,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: chunter.class.ChunterSpace,
|
||||
spaceClass: chunter.class.Channel,
|
||||
enabled: true,
|
||||
order: 30
|
||||
},
|
||||
@@ -90,7 +90,7 @@ export function createModel (builder: Builder): void {
|
||||
application: chunter.app.Chunter,
|
||||
role: AccountRole.ReadOnlyGuest,
|
||||
permissions: [],
|
||||
spaceClass: chunter.class.ChunterSpace,
|
||||
spaceClass: chunter.class.Channel,
|
||||
enabled: true,
|
||||
order: 15
|
||||
},
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
type SocialIdentity,
|
||||
type Status,
|
||||
type SocialIdentityProvider,
|
||||
type Translation
|
||||
type Translation,
|
||||
type WorkspaceMemberStatus
|
||||
} from '@hcengineering/contact'
|
||||
import {
|
||||
AccountRole,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
type PersonId,
|
||||
type PersonUuid,
|
||||
type Ref,
|
||||
type Space,
|
||||
type Timestamp
|
||||
} from '@hcengineering/core'
|
||||
import { createSystemType } from '@hcengineering/model-card'
|
||||
@@ -72,6 +74,7 @@ import {
|
||||
TypeRef,
|
||||
TypeString,
|
||||
TypeTimestamp,
|
||||
TypeAccountUuid,
|
||||
UX,
|
||||
type Builder
|
||||
} from '@hcengineering/model'
|
||||
@@ -301,6 +304,24 @@ export class TTranslation extends TPreference implements Translation {
|
||||
dontTranslate!: string[]
|
||||
}
|
||||
|
||||
@Model(contact.class.WorkspaceMemberStatus, core.class.Doc, DOMAIN_CONTACT)
|
||||
@UX(contact.string.WorkspaceStatusNote, contact.icon.User)
|
||||
export class TWorkspaceMemberStatus extends TDoc implements WorkspaceMemberStatus {
|
||||
@Prop(TypeRef(core.class.Space), core.string.Space)
|
||||
@Index(IndexKind.Indexed)
|
||||
declare space: Ref<Space>
|
||||
|
||||
@Prop(TypeAccountUuid(), core.string.Members)
|
||||
@Index(IndexKind.Indexed)
|
||||
user!: AccountUuid
|
||||
|
||||
@Prop(TypeString(), core.string.Description)
|
||||
message!: string
|
||||
|
||||
@Prop(TypeTimestamp(), contact.string.StatusDueDate)
|
||||
clearAt?: Timestamp
|
||||
}
|
||||
|
||||
export function createModel (builder: Builder): void {
|
||||
builder.createModel(
|
||||
TAvatarProvider,
|
||||
@@ -317,13 +338,20 @@ export function createModel (builder: Builder): void {
|
||||
TContactsTab,
|
||||
TPersonSpace,
|
||||
TUserRole,
|
||||
TTranslation
|
||||
TTranslation,
|
||||
TWorkspaceMemberStatus
|
||||
)
|
||||
|
||||
builder.mixin(contact.class.PersonSpace, core.class.Class, core.mixin.TxAccessLevel, {
|
||||
createAccessLevel: AccountRole.Guest
|
||||
})
|
||||
|
||||
builder.mixin(contact.class.WorkspaceMemberStatus, core.class.Class, core.mixin.TxAccessLevel, {
|
||||
createAccessLevel: AccountRole.Guest,
|
||||
updateAccessLevel: AccountRole.Guest,
|
||||
removeAccessLevel: AccountRole.Guest
|
||||
})
|
||||
|
||||
builder.mixin(contact.class.Person, core.class.Class, core.mixin.TxAccessLevel, {
|
||||
createAccessLevel: AccountRole.Guest,
|
||||
isIdentity: true
|
||||
@@ -1401,6 +1429,12 @@ export function createModel (builder: Builder): void {
|
||||
_class: 1,
|
||||
[contact.mixin.Employee + '.active']: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
_class: 1,
|
||||
user: 1
|
||||
}
|
||||
}
|
||||
],
|
||||
disabled: [{ attachedToClass: 1 }, { modifiedBy: 1 }, { createdBy: 1 }, { createdOn: -1 }, { attachedTo: 1 }]
|
||||
|
||||
@@ -96,12 +96,19 @@ function defineRelationMetadata (builder: Builder): void {
|
||||
|
||||
rel(documents.class.Document, 'category', documents.class.DocumentCategory, 'forward')
|
||||
rel(documents.class.Document, 'template', documents.class.Document, 'forward')
|
||||
rel(documents.class.Document, 'meta', documents.class.ProjectMeta, 'inverse')
|
||||
rel(documents.class.Document, 'document', documents.class.ProjectDocument, 'inverse')
|
||||
rel(documents.class.HierarchyDocument, 'attachedTo', documents.class.DocumentMeta, 'forward')
|
||||
rel(documents.class.ControlledDocument, 'changeControl', documents.class.ChangeControl, 'forward')
|
||||
rel(documents.class.ProjectMeta, 'meta', documents.class.ProjectMeta, 'inverse')
|
||||
rel(documents.class.ProjectMeta, 'document', documents.class.ProjectDocument, 'inverse')
|
||||
rel(documents.class.DocumentMeta, 'meta', documents.class.ProjectMeta, 'inverse')
|
||||
rel(documents.class.Project, 'project', documents.class.ProjectMeta, 'inverse')
|
||||
rel(documents.class.ProjectMeta, 'meta', documents.class.DocumentMeta, 'forward')
|
||||
rel(documents.class.ProjectMeta, 'parent', documents.class.DocumentMeta, 'forward')
|
||||
rel(documents.class.ProjectMeta, 'path', documents.class.DocumentMeta, 'forward')
|
||||
rel(documents.class.ProjectMeta, 'project', documents.class.Project, 'forward')
|
||||
rel(documents.class.ProjectDocument, 'project', documents.class.Project, 'forward')
|
||||
rel(documents.class.ProjectDocument, 'initial', documents.class.Project, 'forward')
|
||||
rel(documents.class.ProjectDocument, 'attachedTo', documents.class.ProjectMeta, 'forward')
|
||||
rel(documents.class.ProjectDocument, 'document', documents.class.Document, 'forward')
|
||||
}
|
||||
|
||||
export function createModel (builder: Builder): void {
|
||||
|
||||
@@ -273,7 +273,6 @@ export function createModel (builder: Builder): void {
|
||||
application: love.app.Love,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: love.class.Office,
|
||||
enabled: true,
|
||||
order: 50
|
||||
},
|
||||
@@ -287,7 +286,6 @@ export function createModel (builder: Builder): void {
|
||||
application: love.app.Love,
|
||||
role: AccountRole.ReadOnlyGuest,
|
||||
permissions: [],
|
||||
spaceClass: love.class.Office,
|
||||
enabled: false,
|
||||
order: 50
|
||||
},
|
||||
|
||||
@@ -220,7 +220,7 @@ export function createModel (builder: Builder): void {
|
||||
application: time.app.Me,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: core.class.TypedSpace,
|
||||
spaceClass: tracker.class.Project,
|
||||
enabled: true,
|
||||
order: 55
|
||||
},
|
||||
@@ -234,7 +234,7 @@ export function createModel (builder: Builder): void {
|
||||
application: time.app.Me,
|
||||
role: AccountRole.ReadOnlyGuest,
|
||||
permissions: [],
|
||||
spaceClass: core.class.TypedSpace,
|
||||
spaceClass: tracker.class.Project,
|
||||
enabled: false,
|
||||
order: 55
|
||||
},
|
||||
|
||||
@@ -917,7 +917,6 @@ function defineSettings (builder: Builder): void {
|
||||
application: training.app.Training,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [training.ids.GuestTrainingAttemptClassPermission],
|
||||
spaceClass: core.class.TypedSpace,
|
||||
enabled: true,
|
||||
order: 25
|
||||
},
|
||||
@@ -931,7 +930,6 @@ function defineSettings (builder: Builder): void {
|
||||
application: training.app.Training,
|
||||
role: AccountRole.ReadOnlyGuest,
|
||||
permissions: [],
|
||||
spaceClass: core.class.TypedSpace,
|
||||
enabled: false,
|
||||
order: 25
|
||||
},
|
||||
|
||||
@@ -80,9 +80,9 @@
|
||||
</div>
|
||||
|
||||
<img class="image" src={getMetadata(calendar.image.Permissions)} alt="" />
|
||||
<div class="footer">
|
||||
<Button label={calendar.string.Connect} kind={'primary'} disabled={connecting} on:click={sendRequest} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<Button label={calendar.string.Connect} kind={'primary'} disabled={connecting} on:click={sendRequest} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,9 +91,12 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 40rem;
|
||||
min-width: 40rem;
|
||||
max-width: 40rem;
|
||||
width: min(40rem, calc(100vw - 2rem));
|
||||
min-width: 0;
|
||||
max-width: min(40rem, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 2rem);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
background-color: var(--popup-bg-hover);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--popup-shadow);
|
||||
@@ -114,10 +117,14 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
color: var(--theme-content-color);
|
||||
margin: 0 1.75rem 0.5rem;
|
||||
margin: 0 1.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.image {
|
||||
@@ -127,11 +134,14 @@
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
padding: 1rem 1.75rem 1.25rem;
|
||||
margin-top: auto;
|
||||
background-color: var(--popup-bg-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -777,7 +777,7 @@ class Connection implements ClientConnection {
|
||||
params: [_class, query, options],
|
||||
measure: (time, result, serverTime, queue, toReceive) => {
|
||||
if (typeof window !== 'undefined' && (time > 1000 || serverTime > 500)) {
|
||||
console.error(
|
||||
console.warn(
|
||||
'measure slow findAll',
|
||||
time,
|
||||
serverTime,
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
"ViewProfile": "Zobrazit profil",
|
||||
"Viber": "Viber",
|
||||
"ViberPlaceholder": "Viber",
|
||||
"SocialId": "Sociální ID",
|
||||
"SocialIds": "Sociální ID",
|
||||
"Type": "Typ",
|
||||
"Confirmed": "Potvrzeno",
|
||||
"UserProfile": "Uživatelský profil",
|
||||
"DeactivatedAccount": "Deaktivovaný účet",
|
||||
"LocalTime": "místní čas",
|
||||
@@ -125,6 +129,26 @@
|
||||
"TranslateTo": "Přeložit do",
|
||||
"DontTranslate": "Nepřekládat",
|
||||
"SelectLanguage": "Vyberte jazyk",
|
||||
"SelectLanguages": "Vyberte jazyky"
|
||||
"SelectLanguages": "Vyberte jazyky",
|
||||
"WorkspaceStatusNote": "Stav uživatele",
|
||||
"WorkspaceStatusUntil": "Zobrazit do",
|
||||
"WorkspaceStatusMenu": "Váš stav",
|
||||
"WorkspaceStatusSetYour": "Nastavte svůj stav",
|
||||
"WorkspaceStatusUpdateYour": "Aktualizujte svůj stav",
|
||||
"WorkspaceStatusMessage": "Jaký je váš stav?",
|
||||
"WorkspaceStatusAway": "Pryč",
|
||||
"WorkspaceStatusVacation": "Na dovolené",
|
||||
"WorkspaceStatusOutSick": "Nemoc",
|
||||
"WorkspaceStatusClear": "Vymazat stav",
|
||||
"WorkspaceStatusSave": "Uložit",
|
||||
"WorkspaceStatusQuickPick": "Rychlé stavy",
|
||||
"WorkspaceStatusComposeHint": "Krátká poznámka (volitelné)",
|
||||
"WorkspaceStatusClearUntil": "Vymazat datum",
|
||||
"WorkspaceStatusDoNotClear": "Stav automaticky nečistit",
|
||||
"WorkspaceStatusIn30Min": "Za 30 minut",
|
||||
"WorkspaceStatusIn1Hour": "Za 1 hodinu",
|
||||
"WorkspaceStatusIn4Hours": "Za 4 hodiny",
|
||||
"WorkspaceStatusEndOfDay": "Do konce dne",
|
||||
"WorkspaceStatusPickDate": "Vyberte datum"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
"ViewProfile": "Profil anzeigen",
|
||||
"Viber": "Viber",
|
||||
"ViberPlaceholder": "Viber",
|
||||
"SocialId": "Social-ID",
|
||||
"SocialIds": "Social-IDs",
|
||||
"Type": "Typ",
|
||||
"Confirmed": "Bestätigt",
|
||||
"UserProfile": "Benutzerprofil",
|
||||
"DeactivatedAccount": "Deaktivierter Account",
|
||||
"LocalTime": "Ortszeit",
|
||||
@@ -125,6 +129,26 @@
|
||||
"TranslateTo": "Übersetzen in",
|
||||
"DontTranslate": "Nicht übersetzen",
|
||||
"SelectLanguage": "Sprache auswählen",
|
||||
"SelectLanguages": "Sprachen auswählen"
|
||||
"SelectLanguages": "Sprachen auswählen",
|
||||
"WorkspaceStatusNote": "Benutzerstatus",
|
||||
"WorkspaceStatusUntil": "Anzeigen bis",
|
||||
"WorkspaceStatusMenu": "Ihr Status",
|
||||
"WorkspaceStatusSetYour": "Status festlegen",
|
||||
"WorkspaceStatusUpdateYour": "Status aktualisieren",
|
||||
"WorkspaceStatusMessage": "Wie lautet Ihr Status?",
|
||||
"WorkspaceStatusAway": "Abwesend",
|
||||
"WorkspaceStatusVacation": "Im Urlaub",
|
||||
"WorkspaceStatusOutSick": "Krank",
|
||||
"WorkspaceStatusClear": "Status löschen",
|
||||
"WorkspaceStatusSave": "Speichern",
|
||||
"WorkspaceStatusQuickPick": "Schnellauswahl",
|
||||
"WorkspaceStatusComposeHint": "Kurze Notiz hinzufügen (optional)",
|
||||
"WorkspaceStatusClearUntil": "Datum löschen",
|
||||
"WorkspaceStatusDoNotClear": "Status nicht automatisch löschen",
|
||||
"WorkspaceStatusIn30Min": "In 30 Minuten",
|
||||
"WorkspaceStatusIn1Hour": "In 1 Stunde",
|
||||
"WorkspaceStatusIn4Hours": "In 4 Stunden",
|
||||
"WorkspaceStatusEndOfDay": "Ende des Tages",
|
||||
"WorkspaceStatusPickDate": "Datum wählen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "Translate to",
|
||||
"DontTranslate": "Don't translate",
|
||||
"SelectLanguage": "Select language",
|
||||
"SelectLanguages": "Select languages"
|
||||
"SelectLanguages": "Select languages",
|
||||
"WorkspaceStatusNote": "User status",
|
||||
"WorkspaceStatusUntil": "Show until",
|
||||
"WorkspaceStatusMenu": "Your status",
|
||||
"WorkspaceStatusSetYour": "Set your status",
|
||||
"WorkspaceStatusUpdateYour": "Update your status",
|
||||
"WorkspaceStatusMessage": "What is your status?",
|
||||
"WorkspaceStatusAway": "Away",
|
||||
"WorkspaceStatusVacation": "On vacation",
|
||||
"WorkspaceStatusOutSick": "Out sick",
|
||||
"WorkspaceStatusClear": "Clear status",
|
||||
"WorkspaceStatusSave": "Save",
|
||||
"WorkspaceStatusQuickPick": "Quick statuses",
|
||||
"WorkspaceStatusComposeHint": "Add a short note (optional)",
|
||||
"WorkspaceStatusClearUntil": "Clear date",
|
||||
"WorkspaceStatusDoNotClear": "Do not clear status",
|
||||
"WorkspaceStatusIn30Min": "In 30 minutes",
|
||||
"WorkspaceStatusIn1Hour": "In 1 hour",
|
||||
"WorkspaceStatusIn4Hours": "In 4 hours",
|
||||
"WorkspaceStatusEndOfDay": "End of day",
|
||||
"WorkspaceStatusPickDate": "Choose date"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "Traducir a",
|
||||
"DontTranslate": "No traducir",
|
||||
"SelectLanguage": "Seleccionar idioma",
|
||||
"SelectLanguages": "Seleccionar idiomas"
|
||||
"SelectLanguages": "Seleccionar idiomas",
|
||||
"WorkspaceStatusNote": "Estado del usuario",
|
||||
"WorkspaceStatusUntil": "Mostrar hasta",
|
||||
"WorkspaceStatusMenu": "Tu estado",
|
||||
"WorkspaceStatusSetYour": "Establecer tu estado",
|
||||
"WorkspaceStatusUpdateYour": "Actualizar tu estado",
|
||||
"WorkspaceStatusMessage": "¿Cuál es tu estado?",
|
||||
"WorkspaceStatusAway": "Ausente",
|
||||
"WorkspaceStatusVacation": "De vacaciones",
|
||||
"WorkspaceStatusOutSick": "De baja por enfermedad",
|
||||
"WorkspaceStatusClear": "Borrar estado",
|
||||
"WorkspaceStatusSave": "Guardar",
|
||||
"WorkspaceStatusQuickPick": "Estados rápidos",
|
||||
"WorkspaceStatusComposeHint": "Añade una nota breve (opcional)",
|
||||
"WorkspaceStatusClearUntil": "Borrar fecha",
|
||||
"WorkspaceStatusDoNotClear": "No borrar el estado automáticamente",
|
||||
"WorkspaceStatusIn30Min": "En 30 minutos",
|
||||
"WorkspaceStatusIn1Hour": "En 1 hora",
|
||||
"WorkspaceStatusIn4Hours": "En 4 horas",
|
||||
"WorkspaceStatusEndOfDay": "Fin del día",
|
||||
"WorkspaceStatusPickDate": "Elegir fecha"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "Traduire en",
|
||||
"DontTranslate": "Ne pas traduire",
|
||||
"SelectLanguage": "Sélectionner la langue",
|
||||
"SelectLanguages": "Sélectionner les langues"
|
||||
"SelectLanguages": "Sélectionner les langues",
|
||||
"WorkspaceStatusNote": "Statut utilisateur",
|
||||
"WorkspaceStatusUntil": "Afficher jusqu’au",
|
||||
"WorkspaceStatusMenu": "Votre statut",
|
||||
"WorkspaceStatusSetYour": "Définir votre statut",
|
||||
"WorkspaceStatusUpdateYour": "Mettre à jour votre statut",
|
||||
"WorkspaceStatusMessage": "Quel est votre statut ?",
|
||||
"WorkspaceStatusAway": "Absent",
|
||||
"WorkspaceStatusVacation": "En congé",
|
||||
"WorkspaceStatusOutSick": "Malade",
|
||||
"WorkspaceStatusClear": "Effacer le statut",
|
||||
"WorkspaceStatusSave": "Enregistrer",
|
||||
"WorkspaceStatusQuickPick": "Statuts rapides",
|
||||
"WorkspaceStatusComposeHint": "Ajouter une courte note (facultatif)",
|
||||
"WorkspaceStatusClearUntil": "Effacer la date",
|
||||
"WorkspaceStatusDoNotClear": "Ne pas effacer le statut automatiquement",
|
||||
"WorkspaceStatusIn30Min": "Dans 30 minutes",
|
||||
"WorkspaceStatusIn1Hour": "Dans 1 heure",
|
||||
"WorkspaceStatusIn4Hours": "Dans 4 heures",
|
||||
"WorkspaceStatusEndOfDay": "Fin de journée",
|
||||
"WorkspaceStatusPickDate": "Choisir une date"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "Traduci in",
|
||||
"DontTranslate": "Non tradurre",
|
||||
"SelectLanguage": "Seleziona lingua",
|
||||
"SelectLanguages": "Seleziona lingue"
|
||||
"SelectLanguages": "Seleziona lingue",
|
||||
"WorkspaceStatusNote": "Stato utente",
|
||||
"WorkspaceStatusUntil": "Mostra fino al",
|
||||
"WorkspaceStatusMenu": "Il tuo stato",
|
||||
"WorkspaceStatusSetYour": "Imposta il tuo stato",
|
||||
"WorkspaceStatusUpdateYour": "Aggiorna il tuo stato",
|
||||
"WorkspaceStatusMessage": "Qual è il tuo stato?",
|
||||
"WorkspaceStatusAway": "Assente",
|
||||
"WorkspaceStatusVacation": "In ferie",
|
||||
"WorkspaceStatusOutSick": "Malato",
|
||||
"WorkspaceStatusClear": "Cancella stato",
|
||||
"WorkspaceStatusSave": "Salva",
|
||||
"WorkspaceStatusQuickPick": "Stati rapidi",
|
||||
"WorkspaceStatusComposeHint": "Aggiungi una breve nota (facoltativo)",
|
||||
"WorkspaceStatusClearUntil": "Cancella data",
|
||||
"WorkspaceStatusDoNotClear": "Non cancellare lo stato automaticamente",
|
||||
"WorkspaceStatusIn30Min": "Tra 30 minuti",
|
||||
"WorkspaceStatusIn1Hour": "Tra 1 ora",
|
||||
"WorkspaceStatusIn4Hours": "Tra 4 ore",
|
||||
"WorkspaceStatusEndOfDay": "Fine giornata",
|
||||
"WorkspaceStatusPickDate": "Scegli data"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "翻訳先",
|
||||
"DontTranslate": "翻訳しない",
|
||||
"SelectLanguage": "言語を選択",
|
||||
"SelectLanguages": "言語を選択"
|
||||
"SelectLanguages": "言語を選択",
|
||||
"WorkspaceStatusNote": "ユーザーステータス",
|
||||
"WorkspaceStatusUntil": "表示期限",
|
||||
"WorkspaceStatusMenu": "あなたのステータス",
|
||||
"WorkspaceStatusSetYour": "ステータスを設定",
|
||||
"WorkspaceStatusUpdateYour": "ステータスを更新",
|
||||
"WorkspaceStatusMessage": "ステータスは何ですか?",
|
||||
"WorkspaceStatusAway": "離席中",
|
||||
"WorkspaceStatusVacation": "休暇中",
|
||||
"WorkspaceStatusOutSick": "病欠",
|
||||
"WorkspaceStatusClear": "ステータスをクリア",
|
||||
"WorkspaceStatusSave": "保存",
|
||||
"WorkspaceStatusQuickPick": "クイックステータス",
|
||||
"WorkspaceStatusComposeHint": "短いメモを追加(任意)",
|
||||
"WorkspaceStatusClearUntil": "日付をクリア",
|
||||
"WorkspaceStatusDoNotClear": "ステータスを自動ではクリアしない",
|
||||
"WorkspaceStatusIn30Min": "30分後",
|
||||
"WorkspaceStatusIn1Hour": "1時間後",
|
||||
"WorkspaceStatusIn4Hours": "4時間後",
|
||||
"WorkspaceStatusEndOfDay": "今日の終わりまで",
|
||||
"WorkspaceStatusPickDate": "日付を選択"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "Traduzir para",
|
||||
"DontTranslate": "Não traduzir",
|
||||
"SelectLanguage": "Selecionar idioma",
|
||||
"SelectLanguages": "Selecionar idiomas"
|
||||
"SelectLanguages": "Selecionar idiomas",
|
||||
"WorkspaceStatusNote": "Status do usuário",
|
||||
"WorkspaceStatusUntil": "Mostrar até",
|
||||
"WorkspaceStatusMenu": "Seu status",
|
||||
"WorkspaceStatusSetYour": "Definir seu status",
|
||||
"WorkspaceStatusUpdateYour": "Atualizar seu status",
|
||||
"WorkspaceStatusMessage": "Qual é o seu status?",
|
||||
"WorkspaceStatusAway": "Ausente",
|
||||
"WorkspaceStatusVacation": "De férias",
|
||||
"WorkspaceStatusOutSick": "Afastado por saúde",
|
||||
"WorkspaceStatusClear": "Limpar status",
|
||||
"WorkspaceStatusSave": "Salvar",
|
||||
"WorkspaceStatusQuickPick": "Status rápidos",
|
||||
"WorkspaceStatusComposeHint": "Adicione uma nota curta (opcional)",
|
||||
"WorkspaceStatusClearUntil": "Limpar data",
|
||||
"WorkspaceStatusDoNotClear": "Não limpar o status automaticamente",
|
||||
"WorkspaceStatusIn30Min": "Em 30 minutos",
|
||||
"WorkspaceStatusIn1Hour": "Em 1 hora",
|
||||
"WorkspaceStatusIn4Hours": "Em 4 horas",
|
||||
"WorkspaceStatusEndOfDay": "Fim do dia",
|
||||
"WorkspaceStatusPickDate": "Escolher data"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "Traduzir para",
|
||||
"DontTranslate": "Não traduzir",
|
||||
"SelectLanguage": "Selecionar idioma",
|
||||
"SelectLanguages": "Selecionar idiomas"
|
||||
"SelectLanguages": "Selecionar idiomas",
|
||||
"WorkspaceStatusNote": "Estado do utilizador",
|
||||
"WorkspaceStatusUntil": "Mostrar até",
|
||||
"WorkspaceStatusMenu": "O seu estado",
|
||||
"WorkspaceStatusSetYour": "Definir o seu estado",
|
||||
"WorkspaceStatusUpdateYour": "Atualizar o seu estado",
|
||||
"WorkspaceStatusMessage": "Qual é o seu estado?",
|
||||
"WorkspaceStatusAway": "Ausente",
|
||||
"WorkspaceStatusVacation": "De férias",
|
||||
"WorkspaceStatusOutSick": "Baixa por doença",
|
||||
"WorkspaceStatusClear": "Limpar estado",
|
||||
"WorkspaceStatusSave": "Guardar",
|
||||
"WorkspaceStatusQuickPick": "Estados rápidos",
|
||||
"WorkspaceStatusComposeHint": "Adicionar uma nota breve (opcional)",
|
||||
"WorkspaceStatusClearUntil": "Limpar data",
|
||||
"WorkspaceStatusDoNotClear": "Não limpar o estado automaticamente",
|
||||
"WorkspaceStatusIn30Min": "Em 30 minutos",
|
||||
"WorkspaceStatusIn1Hour": "Em 1 hora",
|
||||
"WorkspaceStatusIn4Hours": "Em 4 horas",
|
||||
"WorkspaceStatusEndOfDay": "Fim do dia",
|
||||
"WorkspaceStatusPickDate": "Escolher data"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "Переводить на",
|
||||
"DontTranslate": "Не переводить",
|
||||
"SelectLanguage": "Выберите язык",
|
||||
"SelectLanguages": "Выберите языки"
|
||||
"SelectLanguages": "Выберите языки",
|
||||
"WorkspaceStatusNote": "Статус пользователя",
|
||||
"WorkspaceStatusUntil": "Показывать до",
|
||||
"WorkspaceStatusMenu": "Ваш статус",
|
||||
"WorkspaceStatusSetYour": "Установить статус",
|
||||
"WorkspaceStatusUpdateYour": "Обновить статус",
|
||||
"WorkspaceStatusMessage": "Какой ваш статус?",
|
||||
"WorkspaceStatusAway": "Отошёл",
|
||||
"WorkspaceStatusVacation": "В отпуске",
|
||||
"WorkspaceStatusOutSick": "На больничном",
|
||||
"WorkspaceStatusClear": "Очистить статус",
|
||||
"WorkspaceStatusSave": "Сохранить",
|
||||
"WorkspaceStatusQuickPick": "Быстрые статусы",
|
||||
"WorkspaceStatusComposeHint": "Краткая заметка (необязательно)",
|
||||
"WorkspaceStatusClearUntil": "Сбросить дату",
|
||||
"WorkspaceStatusDoNotClear": "Не сбрасывать статус автоматически",
|
||||
"WorkspaceStatusIn30Min": "Через 30 минут",
|
||||
"WorkspaceStatusIn1Hour": "Через 1 час",
|
||||
"WorkspaceStatusIn4Hours": "Через 4 часа",
|
||||
"WorkspaceStatusEndOfDay": "До конца дня",
|
||||
"WorkspaceStatusPickDate": "Выбрать дату"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,31 @@
|
||||
"HereDescription": "Bu {title} içindeki çevrimiçi üyeleri bilgilendir",
|
||||
"Guest": "Misafir",
|
||||
"Deleted": "Silindi",
|
||||
"CannotMerge": "Hata: Global kişiler birleştirilemez"
|
||||
"CannotMerge": "Hata: Global kişiler birleştirilemez",
|
||||
"AutoTranslation": "Otomatik çeviri",
|
||||
"TranslateTo": "Şuna çevir",
|
||||
"DontTranslate": "Çevirme",
|
||||
"SelectLanguage": "Dil seç",
|
||||
"SelectLanguages": "Dilleri seç",
|
||||
"WorkspaceStatusNote": "Kullanıcı durumu",
|
||||
"WorkspaceStatusUntil": "Şu tarihe kadar göster",
|
||||
"WorkspaceStatusMenu": "Durumunuz",
|
||||
"WorkspaceStatusSetYour": "Durumunuzu ayarlayın",
|
||||
"WorkspaceStatusUpdateYour": "Durumunuzu güncelleyin",
|
||||
"WorkspaceStatusMessage": "Durumunuz nedir?",
|
||||
"WorkspaceStatusAway": "Uzakta",
|
||||
"WorkspaceStatusVacation": "Tatilde",
|
||||
"WorkspaceStatusOutSick": "Hastalık izni",
|
||||
"WorkspaceStatusClear": "Durumu temizle",
|
||||
"WorkspaceStatusSave": "Kaydet",
|
||||
"WorkspaceStatusQuickPick": "Hızlı durumlar",
|
||||
"WorkspaceStatusComposeHint": "Kısa bir not ekleyin (isteğe bağlı)",
|
||||
"WorkspaceStatusClearUntil": "Tarihi temizle",
|
||||
"WorkspaceStatusDoNotClear": "Durumu otomatik temizleme",
|
||||
"WorkspaceStatusIn30Min": "30 dakika içinde",
|
||||
"WorkspaceStatusIn1Hour": "1 saat içinde",
|
||||
"WorkspaceStatusIn4Hours": "4 saat içinde",
|
||||
"WorkspaceStatusEndOfDay": "Gün sonu",
|
||||
"WorkspaceStatusPickDate": "Tarih seç"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,26 @@
|
||||
"TranslateTo": "翻译为",
|
||||
"DontTranslate": "不翻译",
|
||||
"SelectLanguage": "选择语言",
|
||||
"SelectLanguages": "选择语言"
|
||||
"SelectLanguages": "选择语言",
|
||||
"WorkspaceStatusNote": "用户状态",
|
||||
"WorkspaceStatusUntil": "显示截止日期",
|
||||
"WorkspaceStatusMenu": "你的状态",
|
||||
"WorkspaceStatusSetYour": "设置你的状态",
|
||||
"WorkspaceStatusUpdateYour": "更新你的状态",
|
||||
"WorkspaceStatusMessage": "你的状态是什么?",
|
||||
"WorkspaceStatusAway": "离开",
|
||||
"WorkspaceStatusVacation": "休假中",
|
||||
"WorkspaceStatusOutSick": "病假",
|
||||
"WorkspaceStatusClear": "清除状态",
|
||||
"WorkspaceStatusSave": "保存",
|
||||
"WorkspaceStatusQuickPick": "快捷状态",
|
||||
"WorkspaceStatusComposeHint": "添加简短备注(可选)",
|
||||
"WorkspaceStatusClearUntil": "清除日期",
|
||||
"WorkspaceStatusDoNotClear": "不自动清除状态",
|
||||
"WorkspaceStatusIn30Min": "30 分钟后",
|
||||
"WorkspaceStatusIn1Hour": "1 小时后",
|
||||
"WorkspaceStatusIn4Hours": "4 小时后",
|
||||
"WorkspaceStatusEndOfDay": "今天结束",
|
||||
"WorkspaceStatusPickDate": "选择日期"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@hcengineering/attachment-resources": "workspace:^0.7.0",
|
||||
"@hcengineering/contact": "workspace:^0.7.0",
|
||||
"@hcengineering/core": "workspace:^0.7.26",
|
||||
"@hcengineering/emoji-resources": "workspace:^0.7.0",
|
||||
"@hcengineering/image-cropper": "workspace:^0.7.0",
|
||||
"@hcengineering/login": "workspace:^0.7.0",
|
||||
"@hcengineering/notification": "workspace:^0.7.0",
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Employee, Person } from '@hcengineering/contact'
|
||||
import {
|
||||
Employee,
|
||||
Person,
|
||||
extractLeadingStatusEmoji,
|
||||
getWorkspaceMemberStatusSubtitle,
|
||||
isWorkspaceMemberStatusVisible
|
||||
} from '@hcengineering/contact'
|
||||
import { Ref, WithLookup } from '@hcengineering/core'
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import ui, { IconSize } from '@hcengineering/ui'
|
||||
import ui, { IconSize, tooltip } from '@hcengineering/ui'
|
||||
import { employeeByIdStore, PersonLabelTooltip } from '..'
|
||||
import { workspaceMemberStatusByAccountStore } from '../workspaceMemberStatus'
|
||||
import PersonPresenter from '../components/PersonPresenter.svelte'
|
||||
import contact from '../plugin'
|
||||
import { getPreviewPopup } from './person/utils'
|
||||
@@ -26,6 +33,7 @@
|
||||
export let noUnderline: boolean = false
|
||||
export let compact: boolean = false
|
||||
export let showStatus: boolean = false
|
||||
export let showWorkspaceStatusEmoji: boolean = true
|
||||
|
||||
const client = getClient()
|
||||
const h = client.getHierarchy()
|
||||
@@ -33,25 +41,58 @@
|
||||
$: person = typeof value === 'string' ? $employeeByIdStore.get(value as Ref<Employee>) : (value as Person)
|
||||
$: employeeValue = person != null ? h.as(person, contact.mixin.Employee) : undefined
|
||||
$: active = employeeValue?.active ?? false
|
||||
$: statusDoc =
|
||||
employeeValue?.personUuid !== undefined
|
||||
? $workspaceMemberStatusByAccountStore.get(employeeValue.personUuid)
|
||||
: undefined
|
||||
|
||||
$: statusEmoji = extractLeadingStatusEmoji(statusDoc?.message)
|
||||
|
||||
$: statusTooltip = getWorkspaceMemberStatusSubtitle(statusDoc)
|
||||
</script>
|
||||
|
||||
<PersonPresenter
|
||||
value={employeeValue}
|
||||
{tooltipLabels}
|
||||
onEdit={onEmployeeEdit}
|
||||
customTooltip={getPreviewPopup(employeeValue, showPopup)}
|
||||
{shouldShowAvatar}
|
||||
{shouldShowName}
|
||||
{avatarSize}
|
||||
{shouldShowPlaceholder}
|
||||
{disabled}
|
||||
{inline}
|
||||
{colorInherit}
|
||||
{accent}
|
||||
{defaultName}
|
||||
{noUnderline}
|
||||
{compact}
|
||||
showStatus={showStatus && active}
|
||||
statusLabel={!active && shouldShowName && showStatus ? contact.string.Inactive : undefined}
|
||||
on:accent-color
|
||||
/>
|
||||
<div class="employee-presenter">
|
||||
<PersonPresenter
|
||||
value={employeeValue}
|
||||
{tooltipLabels}
|
||||
onEdit={onEmployeeEdit}
|
||||
customTooltip={getPreviewPopup(employeeValue, showPopup)}
|
||||
{shouldShowAvatar}
|
||||
{shouldShowName}
|
||||
{avatarSize}
|
||||
{shouldShowPlaceholder}
|
||||
{disabled}
|
||||
{inline}
|
||||
{colorInherit}
|
||||
{accent}
|
||||
{defaultName}
|
||||
{noUnderline}
|
||||
{compact}
|
||||
showStatus={showStatus && active}
|
||||
statusLabel={!active && shouldShowName && showStatus ? contact.string.Inactive : undefined}
|
||||
on:accent-color
|
||||
/>
|
||||
{#if showWorkspaceStatusEmoji && shouldShowName && statusEmoji !== undefined && isWorkspaceMemberStatusVisible(statusDoc)}
|
||||
<span
|
||||
class="status-emoji"
|
||||
use:tooltip={statusTooltip !== undefined ? { label: getEmbeddedLabel(statusTooltip) } : undefined}
|
||||
>
|
||||
{statusEmoji}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.employee-presenter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.status-emoji {
|
||||
line-height: 1;
|
||||
font-size: 0.875rem;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 contact, { type WorkspaceMemberStatus, trimWorkspaceMemberStatusMessage } from '@hcengineering/contact'
|
||||
import core, { getCurrentAccount } from '@hcengineering/core'
|
||||
import EmojiPopup from '@hcengineering/emoji-resources/src/components/EmojiPopup.svelte'
|
||||
import { translate, type IntlString } from '@hcengineering/platform'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { getCurrentLanguage } from '@hcengineering/theme'
|
||||
import {
|
||||
Button,
|
||||
DatePopup,
|
||||
EditBox,
|
||||
Header,
|
||||
Label,
|
||||
SelectPopup,
|
||||
type SelectPopupValueType,
|
||||
eventToHTMLElement,
|
||||
showPopup,
|
||||
closePopup
|
||||
} from '@hcengineering/ui'
|
||||
|
||||
import contactPlugin from '../plugin'
|
||||
|
||||
const client = getClient()
|
||||
const dispatch = createEventDispatcher()
|
||||
const account = getCurrentAccount()
|
||||
|
||||
let message = ''
|
||||
type UiPreset = 'away' | 'vacation' | 'outSick' | 'custom'
|
||||
let preset: UiPreset = 'custom'
|
||||
let emojiText = '💬'
|
||||
let clearAt: number | undefined = undefined
|
||||
let existing: WorkspaceMemberStatus | undefined = undefined
|
||||
type ClearMode = 'none' | 'in30m' | 'in1h' | 'in4h' | 'endOfDay' | 'pickDate'
|
||||
let clearMode: ClearMode = 'none'
|
||||
|
||||
const CLEAR_MODE_LABEL: Record<ClearMode, IntlString> = {
|
||||
none: contactPlugin.string.WorkspaceStatusDoNotClear,
|
||||
in30m: contactPlugin.string.WorkspaceStatusIn30Min,
|
||||
in1h: contactPlugin.string.WorkspaceStatusIn1Hour,
|
||||
in4h: contactPlugin.string.WorkspaceStatusIn4Hours,
|
||||
endOfDay: contactPlugin.string.WorkspaceStatusEndOfDay,
|
||||
pickDate: contactPlugin.string.WorkspaceStatusPickDate
|
||||
}
|
||||
|
||||
const CLEAR_MODE_MENU_ORDER: ClearMode[] = ['none', 'in30m', 'in1h', 'in4h', 'endOfDay', 'pickDate']
|
||||
|
||||
const presets: Array<{ id: Exclude<UiPreset, 'custom'>, emoji: string, label: IntlString }> = [
|
||||
{ id: 'away', emoji: '⏳', label: contactPlugin.string.WorkspaceStatusAway },
|
||||
{ id: 'vacation', emoji: '🏖️', label: contactPlugin.string.WorkspaceStatusVacation },
|
||||
{ id: 'outSick', emoji: '🤒', label: contactPlugin.string.WorkspaceStatusOutSick }
|
||||
]
|
||||
|
||||
/** Avoid flipping UI preset to `custom` when we programmatically set `message` from a template */
|
||||
let skipMarkCustom = false
|
||||
|
||||
function presetEmoji (value: UiPreset): string {
|
||||
if (value === 'away') return '⏳'
|
||||
if (value === 'vacation') return '🏖️'
|
||||
if (value === 'outSick') return '🤒'
|
||||
return '💬'
|
||||
}
|
||||
|
||||
function emojiToUiPreset (e: string): UiPreset {
|
||||
if (e === '⏳') return 'away'
|
||||
if (e === '🏖️') return 'vacation'
|
||||
if (e === '🤒') return 'outSick'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
function splitStoredMessage (raw: string): { emoji: string, text: string } {
|
||||
const t = raw.trim()
|
||||
if (t === '') return { emoji: '💬', text: '' }
|
||||
const sp = t.indexOf(' ')
|
||||
if (sp === -1) return { emoji: t, text: '' }
|
||||
return { emoji: t.slice(0, sp), text: t.slice(sp + 1).trim() }
|
||||
}
|
||||
|
||||
function markCustom (): void {
|
||||
if (skipMarkCustom) return
|
||||
preset = 'custom'
|
||||
}
|
||||
|
||||
async function applyTemplate (template: Exclude<UiPreset, 'custom'>): Promise<void> {
|
||||
skipMarkCustom = true
|
||||
preset = template
|
||||
emojiText = presetEmoji(template)
|
||||
const lang = getCurrentLanguage()
|
||||
const row = presets.find((p) => p.id === template)
|
||||
if (row !== undefined) {
|
||||
message = await translate(row.label, {}, lang)
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
skipMarkCustom = false
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const doc = await client.findOne(contact.class.WorkspaceMemberStatus, { user: account.uuid })
|
||||
existing = doc
|
||||
if (doc === undefined) return
|
||||
|
||||
clearAt = doc.clearAt
|
||||
syncClearModeFromTimestamp()
|
||||
const legacyPreset = (doc as WorkspaceMemberStatus & { preset?: string }).preset
|
||||
const raw = (doc.message ?? '').trim()
|
||||
|
||||
if (legacyPreset === 'vacation' && raw === '') {
|
||||
await applyTemplate('vacation')
|
||||
return
|
||||
}
|
||||
|
||||
const { emoji, text } = splitStoredMessage(doc.message ?? '')
|
||||
message = text
|
||||
emojiText = emoji !== '' ? emoji : '💬'
|
||||
preset = emojiToUiPreset(emoji)
|
||||
})()
|
||||
})
|
||||
|
||||
$: clearModeLabel = CLEAR_MODE_LABEL[clearMode]
|
||||
|
||||
$: untilPickerDateLabel = clearAt !== undefined ? new Date(clearAt).toLocaleDateString() : ''
|
||||
|
||||
$: untilPickerTimeInputValue =
|
||||
clearAt === undefined
|
||||
? '09:00'
|
||||
: `${String(new Date(clearAt).getHours()).padStart(2, '0')}:${String(new Date(clearAt).getMinutes()).padStart(2, '0')}`
|
||||
|
||||
function getEndOfDayTimestamp (): number {
|
||||
const d = new Date()
|
||||
d.setHours(23, 59, 59, 999)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
/** Best-effort: stored status only has `clearAt`, not which menu option was chosen */
|
||||
function inferClearModeFromTimestamp (ts: number): ClearMode {
|
||||
const now = Date.now()
|
||||
const toleranceMs = 90 * 1000
|
||||
if (ts <= now) return 'none'
|
||||
|
||||
const at = new Date(ts)
|
||||
const endOfThatDay = new Date(at)
|
||||
endOfThatDay.setHours(23, 59, 59, 999)
|
||||
if (Math.abs(ts - endOfThatDay.getTime()) < toleranceMs) return 'endOfDay'
|
||||
|
||||
const diff = ts - now
|
||||
if (diff > 0 && diff <= 6 * 60 * 60 * 1000) {
|
||||
const near = (ms: number): boolean => Math.abs(diff - ms) < toleranceMs
|
||||
if (near(30 * 60 * 1000)) return 'in30m'
|
||||
if (near(60 * 60 * 1000)) return 'in1h'
|
||||
if (near(4 * 60 * 60 * 1000)) return 'in4h'
|
||||
}
|
||||
return 'pickDate'
|
||||
}
|
||||
|
||||
function applyClearMode (mode: ClearMode): void {
|
||||
clearMode = mode
|
||||
const now = Date.now()
|
||||
if (mode === 'none') {
|
||||
clearAt = undefined
|
||||
return
|
||||
}
|
||||
if (mode === 'in30m') {
|
||||
clearAt = now + 30 * 60 * 1000
|
||||
return
|
||||
}
|
||||
if (mode === 'in1h') {
|
||||
clearAt = now + 60 * 60 * 1000
|
||||
return
|
||||
}
|
||||
if (mode === 'in4h') {
|
||||
clearAt = now + 4 * 60 * 60 * 1000
|
||||
return
|
||||
}
|
||||
if (mode === 'endOfDay') {
|
||||
clearAt = getEndOfDayTimestamp()
|
||||
}
|
||||
}
|
||||
|
||||
function syncClearModeFromTimestamp (): void {
|
||||
if (clearAt === undefined) {
|
||||
clearMode = 'none'
|
||||
return
|
||||
}
|
||||
const inferred = inferClearModeFromTimestamp(clearAt)
|
||||
if (inferred === 'none') {
|
||||
clearAt = undefined
|
||||
clearMode = 'none'
|
||||
return
|
||||
}
|
||||
clearMode = inferred
|
||||
}
|
||||
|
||||
function finishPopup (): void {
|
||||
closePopup()
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
function openUntilDatePicker (ev: MouseEvent): void {
|
||||
showPopup(
|
||||
DatePopup,
|
||||
{
|
||||
noShift: true,
|
||||
withTime: true,
|
||||
currentDate: clearAt !== undefined ? new Date(clearAt) : null,
|
||||
label: contactPlugin.string.WorkspaceStatusUntil
|
||||
},
|
||||
eventToHTMLElement(ev),
|
||||
(result) => {
|
||||
if (result != null && result.value !== undefined) {
|
||||
clearAt = result.value !== null ? result.value.getTime() : undefined
|
||||
clearMode = clearAt === undefined ? 'none' : 'pickDate'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function openClearModeMenu (ev: MouseEvent): Promise<void> {
|
||||
const lang = getCurrentLanguage()
|
||||
const value: SelectPopupValueType[] = await Promise.all(
|
||||
CLEAR_MODE_MENU_ORDER.map(async (id) => ({
|
||||
id,
|
||||
text: await translate(CLEAR_MODE_LABEL[id], {}, lang)
|
||||
}))
|
||||
)
|
||||
|
||||
showPopup(
|
||||
SelectPopup,
|
||||
{
|
||||
value,
|
||||
onSelect: (id?: string | number | null) => {
|
||||
if (id == null) return
|
||||
const mode = String(id) as ClearMode
|
||||
if (mode === 'pickDate') {
|
||||
clearMode = 'pickDate'
|
||||
// SelectPopup does not dispatch `close` when `onSelect` is set — dismiss menu before stacking DatePopup.
|
||||
closePopup()
|
||||
openUntilDatePicker(ev)
|
||||
return
|
||||
}
|
||||
applyClearMode(mode)
|
||||
closePopup()
|
||||
}
|
||||
},
|
||||
eventToHTMLElement(ev)
|
||||
)
|
||||
}
|
||||
|
||||
function onTimeChange (ev: Event): void {
|
||||
if (clearAt === undefined) return
|
||||
const value = (ev.target as HTMLInputElement).value
|
||||
const [h, m] = value.split(':').map((it) => Number(it))
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return
|
||||
const d = new Date(clearAt)
|
||||
d.setHours(h, m, 0, 0)
|
||||
clearAt = d.getTime()
|
||||
}
|
||||
|
||||
function openEmojiPicker (ev: MouseEvent): void {
|
||||
const target = ev.currentTarget as HTMLElement
|
||||
showPopup(EmojiPopup, { selected: emojiText }, target, (result: { text?: string, image?: unknown } | undefined) => {
|
||||
if (result?.text === undefined) return
|
||||
if (result.image !== undefined) return
|
||||
emojiText = result.text
|
||||
})
|
||||
}
|
||||
|
||||
async function save (): Promise<void> {
|
||||
const trimmed = trimWorkspaceMemberStatusMessage(message)
|
||||
const chosenEmoji = emojiText.trim() !== '' ? emojiText.trim() : presetEmoji(preset)
|
||||
const finalMessage = `${chosenEmoji} ${trimmed}`.trim()
|
||||
const empty =
|
||||
trimmed === '' && chosenEmoji === presetEmoji('custom') && (clearAt === undefined || clearAt <= Date.now())
|
||||
|
||||
if (empty) {
|
||||
if (existing !== undefined) {
|
||||
await client.remove(existing)
|
||||
}
|
||||
finishPopup()
|
||||
return
|
||||
}
|
||||
|
||||
if (existing !== undefined) {
|
||||
await client.update(existing, {
|
||||
message: finalMessage,
|
||||
...(clearAt !== undefined ? { clearAt } : { $unset: { clearAt: true } })
|
||||
})
|
||||
} else {
|
||||
await client.createDoc(contact.class.WorkspaceMemberStatus, core.space.Workspace, {
|
||||
space: core.space.Workspace,
|
||||
user: account.uuid,
|
||||
message: finalMessage,
|
||||
...(clearAt !== undefined ? { clearAt } : {})
|
||||
} as any)
|
||||
}
|
||||
finishPopup()
|
||||
}
|
||||
|
||||
async function clearAll (): Promise<void> {
|
||||
if (existing !== undefined) {
|
||||
await client.remove(existing)
|
||||
}
|
||||
message = ''
|
||||
preset = 'custom'
|
||||
emojiText = presetEmoji('custom')
|
||||
clearAt = undefined
|
||||
existing = undefined
|
||||
finishPopup()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="status-modal antiPopup antiPopup-withHeader clear-mins">
|
||||
<div class="status-modal__header ap-spaceHeader">
|
||||
<Header type={'type-popup'} adaptive={'disabled'} on:close={finishPopup}>
|
||||
<div class="status-modal__title fs-title">
|
||||
<Label label={contactPlugin.string.WorkspaceStatusMenu} />
|
||||
</div>
|
||||
</Header>
|
||||
</div>
|
||||
|
||||
<div class="status-modal__body ap-spaceContent flex-col flex-gap-3 p-4">
|
||||
<div class="status-input-row">
|
||||
<div class="status-input-row__emoji">
|
||||
<Button
|
||||
kind={'ghost'}
|
||||
size={'large'}
|
||||
padding={'0 0.25rem'}
|
||||
minWidth={'2.5rem'}
|
||||
justify={'center'}
|
||||
on:click={openEmojiPicker}
|
||||
>
|
||||
<svelte:fragment slot="content">
|
||||
<span class="text-lg leading-none">{emojiText}</span>
|
||||
</svelte:fragment>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="status-input-grow">
|
||||
<EditBox
|
||||
bind:value={message}
|
||||
placeholder={contactPlugin.string.WorkspaceStatusMessage}
|
||||
on:change={markCustom}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-modal__ghost-bordered flex-col flex-gap-2 w-full">
|
||||
{#each presets as item}
|
||||
<Button
|
||||
kind={'ghost'}
|
||||
size={'large'}
|
||||
width={'100%'}
|
||||
justify={'left'}
|
||||
on:click={() => {
|
||||
void applyTemplate(item.id)
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="content">
|
||||
<span class="flex-row-center flex-gap-3 min-w-0">
|
||||
<span class="text-lg leading-none shrink-0" aria-hidden="true">{item.emoji}</span>
|
||||
<span class="overflow-label"><Label label={item.label} /></span>
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="status-modal__clearAtTitle text-sm content-dark-color">
|
||||
<Label label={contactPlugin.string.WorkspaceStatusUntil} />
|
||||
</div>
|
||||
<div class="status-modal__ghost-bordered w-full">
|
||||
<Button
|
||||
kind={'ghost'}
|
||||
size={'large'}
|
||||
width={'100%'}
|
||||
justify={'left'}
|
||||
on:click={(ev) => {
|
||||
void openClearModeMenu(ev)
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="content">
|
||||
<span class="overflow-label"><Label label={clearModeLabel} /></span>
|
||||
</svelte:fragment>
|
||||
</Button>
|
||||
</div>
|
||||
{#if clearAt !== undefined && clearMode === 'pickDate'}
|
||||
<div class="flex-row-center flex-gap-2 w-full">
|
||||
<div class="status-modal__ghost-bordered flex-grow min-w-0">
|
||||
<Button kind={'ghost'} size={'large'} width={'100%'} justify={'left'} on:click={openUntilDatePicker}>
|
||||
<svelte:fragment slot="content">
|
||||
<span class="text-sm content-dark-color">{untilPickerDateLabel}</span>
|
||||
</svelte:fragment>
|
||||
</Button>
|
||||
</div>
|
||||
{#key clearAt}
|
||||
<input
|
||||
class="until-row__time ml-auto content-color"
|
||||
type="time"
|
||||
value={untilPickerTimeInputValue}
|
||||
on:change={onTimeChange}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="status-modal__footer ap-spaceContent flex-row-center flex-gap-2 flex-reverse px-4">
|
||||
<Button
|
||||
label={contactPlugin.string.WorkspaceStatusSave}
|
||||
kind={'primary'}
|
||||
size={'large'}
|
||||
on:click={() => {
|
||||
save().catch(() => {})
|
||||
}}
|
||||
/>
|
||||
<div class="status-modal__ghost-bordered">
|
||||
<Button
|
||||
label={contactPlugin.string.WorkspaceStatusClear}
|
||||
kind={'ghost'}
|
||||
size={'large'}
|
||||
on:click={() => {
|
||||
clearAll().catch(() => {})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@media (min-width: 480px) {
|
||||
.status-modal {
|
||||
min-width: 22rem;
|
||||
}
|
||||
}
|
||||
|
||||
.status-modal__header.ap-spaceHeader {
|
||||
border-bottom: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.status-modal__header {
|
||||
padding-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status-modal__title {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.status-input-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
border: 1px solid var(--theme-divider-color);
|
||||
border-radius: var(--medium-BorderRadius);
|
||||
background-color: transparent;
|
||||
min-height: 2.25rem;
|
||||
overflow: visible;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Match default EditBox focus (see packages/theme/styles/editors.scss .default:focus-within) */
|
||||
.status-input-row:focus-within {
|
||||
border-color: var(--primary-button-default);
|
||||
box-shadow: 0 0 0 1px var(--theme-editbox-focus-border);
|
||||
}
|
||||
|
||||
.status-input-row :global(> .antiButton) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
align-self: stretch;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.status-modal__ghost-bordered :global(.antiButton.ghost) {
|
||||
border: 1px solid var(--theme-divider-color);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status-modal__clearAtTitle {
|
||||
margin-bottom: -0.25rem;
|
||||
}
|
||||
|
||||
.status-input-grow {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-input-grow :global(.antiEditBox > .editbox) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.status-input-grow :global(.antiEditBox .antiEditBoxInput) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.status-input-grow :global(.antiEditBox .antiEditBoxInput:focus),
|
||||
.status-input-grow :global(.antiEditBox .antiEditBoxInput:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.until-row__time {
|
||||
min-width: 7rem;
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--theme-divider-color);
|
||||
border-radius: var(--small-BorderRadius);
|
||||
background: transparent;
|
||||
padding: 0.3rem 0.45rem;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) .until-row__time {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:global(html.theme-light) .until-row__time {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.status-modal__footer {
|
||||
border-top: none;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Employee, Person } from '@hcengineering/contact'
|
||||
import { Employee, Person, getWorkspaceMemberStatusSubtitle } from '@hcengineering/contact'
|
||||
import { AccountUuid, Class, Doc, Ref } from '@hcengineering/core'
|
||||
import { ComponentExtensions, createQuery, getClient, hasResource } from '@hcengineering/presentation'
|
||||
import { ButtonIcon, Component, navigate } from '@hcengineering/ui'
|
||||
@@ -24,6 +24,7 @@
|
||||
import { EmployeePresenter, getPersonByPersonRefStore } from '../../index'
|
||||
import contact from '../../plugin'
|
||||
import { employeeByIdStore } from '../../utils'
|
||||
import { workspaceMemberStatusByAccountStore } from '../../workspaceMemberStatus'
|
||||
import Avatar from '../Avatar.svelte'
|
||||
import DeactivatedHeader from './DeactivatedHeader.svelte'
|
||||
import ModernProfilePopup from './ModernProfilePopup.svelte'
|
||||
@@ -73,6 +74,11 @@
|
||||
timezone = await getPersonTimezone(person?.personUuid as AccountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
$: statusSubtitle =
|
||||
employee?.personUuid !== undefined
|
||||
? getWorkspaceMemberStatusSubtitle($workspaceMemberStatusByAccountStore.get(employee.personUuid as AccountUuid))
|
||||
: undefined
|
||||
</script>
|
||||
|
||||
<ModernProfilePopup {disabled}>
|
||||
@@ -108,8 +114,21 @@
|
||||
on:click={viewProfile}
|
||||
/>
|
||||
<div class="flex-col flex-gap-0-5 pl-1">
|
||||
<div class="status-container" />
|
||||
<EmployeePresenter value={employee} shouldShowAvatar={false} showPopup={false} compact accent />
|
||||
{#if statusSubtitle}
|
||||
<div class="status-container max-w-60">
|
||||
<div class="status-container__text text-normal font-normal content-color overflow-label">
|
||||
{statusSubtitle}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<EmployeePresenter
|
||||
value={employee}
|
||||
shouldShowAvatar={false}
|
||||
showPopup={false}
|
||||
compact
|
||||
accent
|
||||
showWorkspaceStatusEmoji={false}
|
||||
/>
|
||||
{#if hasRating}
|
||||
<div class="flex-row-center text-sm">
|
||||
<Component
|
||||
@@ -176,7 +195,16 @@
|
||||
background-color: var(--theme-button-container-color);
|
||||
}
|
||||
.status-container {
|
||||
display: flex;
|
||||
min-height: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--medium-BorderRadius);
|
||||
background: color-mix(in srgb, var(--theme-popup-color) 72%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--theme-popup-color) 55%, var(--theme-divider-color));
|
||||
}
|
||||
|
||||
.status-container__text {
|
||||
line-height: 1.25;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -110,6 +110,7 @@ import SelectAvatars from './components/SelectAvatars.svelte'
|
||||
import SelectUsersPopup from './components/SelectUsersPopup.svelte'
|
||||
import SocialEditor from './components/SocialEditor.svelte'
|
||||
import SpaceMembers from './components/SpaceMembers.svelte'
|
||||
import WorkspaceMemberStatusEditor from './components/WorkspaceMemberStatusEditor.svelte'
|
||||
import SpaceMembersEditor from './components/SpaceMembersEditor.svelte'
|
||||
import SystemAvatar from './components/SystemAvatar.svelte'
|
||||
import UserBox from './components/UserBox.svelte'
|
||||
@@ -155,6 +156,7 @@ import {
|
||||
} from './utils'
|
||||
|
||||
export * from './utils'
|
||||
export * from './workspaceMemberStatus'
|
||||
export { employeeByIdStore } from './utils'
|
||||
export * from './assignee'
|
||||
export * from './translation'
|
||||
@@ -199,6 +201,7 @@ export {
|
||||
SelectAvatars,
|
||||
SelectUsersPopup,
|
||||
SpaceMembers,
|
||||
WorkspaceMemberStatusEditor,
|
||||
SystemAvatar,
|
||||
UserBox,
|
||||
UserBoxItems,
|
||||
@@ -403,7 +406,8 @@ export default async (): Promise<Resources> => ({
|
||||
PersonIdFilter,
|
||||
AssigneePopup,
|
||||
TranslationSettings,
|
||||
SocialIdentityPresenter
|
||||
SocialIdentityPresenter,
|
||||
WorkspaceMemberStatusEditor
|
||||
},
|
||||
completion: {
|
||||
EmployeeQuery: async (
|
||||
|
||||
@@ -90,7 +90,26 @@ export default mergeIds(contactId, contact, {
|
||||
TranslateTo: '' as IntlString,
|
||||
DontTranslate: '' as IntlString,
|
||||
SelectLanguage: '' as IntlString,
|
||||
SelectLanguages: '' as IntlString
|
||||
SelectLanguages: '' as IntlString,
|
||||
WorkspaceStatusUntil: '' as IntlString,
|
||||
WorkspaceStatusMenu: '' as IntlString,
|
||||
WorkspaceStatusMessage: '' as IntlString,
|
||||
WorkspaceStatusSetYour: '' as IntlString,
|
||||
WorkspaceStatusUpdateYour: '' as IntlString,
|
||||
WorkspaceStatusAway: '' as IntlString,
|
||||
WorkspaceStatusVacation: '' as IntlString,
|
||||
WorkspaceStatusOutSick: '' as IntlString,
|
||||
WorkspaceStatusClear: '' as IntlString,
|
||||
WorkspaceStatusSave: '' as IntlString,
|
||||
WorkspaceStatusQuickPick: '' as IntlString,
|
||||
WorkspaceStatusComposeHint: '' as IntlString,
|
||||
WorkspaceStatusClearUntil: '' as IntlString,
|
||||
WorkspaceStatusDoNotClear: '' as IntlString,
|
||||
WorkspaceStatusIn30Min: '' as IntlString,
|
||||
WorkspaceStatusIn1Hour: '' as IntlString,
|
||||
WorkspaceStatusIn4Hours: '' as IntlString,
|
||||
WorkspaceStatusEndOfDay: '' as IntlString,
|
||||
WorkspaceStatusPickDate: '' as IntlString
|
||||
},
|
||||
function: {
|
||||
GetContactLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { loadWorkspaceMemberStatuses } from './workspaceMemberStatus'
|
||||
|
||||
import { type AccountClient, getClient as getAccountClientRaw } from '@hcengineering/account-client'
|
||||
import {
|
||||
addEmployeeListenrer,
|
||||
@@ -411,6 +413,8 @@ onClient(() => {
|
||||
channelProviders.set(res)
|
||||
})
|
||||
|
||||
loadWorkspaceMemberStatuses()
|
||||
|
||||
employeesQuery.query(
|
||||
contact.mixin.Employee,
|
||||
{},
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// Copyright © 2026 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 contact, { type WorkspaceMemberStatus } from '@hcengineering/contact'
|
||||
import type { AccountUuid } from '@hcengineering/core'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
|
||||
export const workspaceMemberStatusByAccountStore = writable<Map<AccountUuid, WorkspaceMemberStatus>>(new Map())
|
||||
|
||||
const workspaceMemberStatusQuery = createQuery(true)
|
||||
let workspaceStatusesLoaded = false
|
||||
|
||||
export function loadWorkspaceMemberStatuses (): void {
|
||||
if (workspaceStatusesLoaded) return
|
||||
workspaceStatusesLoaded = true
|
||||
workspaceMemberStatusQuery.query(contact.class.WorkspaceMemberStatus, {}, (res) => {
|
||||
workspaceMemberStatusByAccountStore.set(new Map(res.map((it) => [it.user, it])))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
WORKSPACE_MEMBER_STATUS_MESSAGE_MAX,
|
||||
extractLeadingStatusEmoji,
|
||||
getWorkspaceMemberStatusSubtitle,
|
||||
isWorkspaceMemberStatusVisible,
|
||||
trimWorkspaceMemberStatusMessage
|
||||
} from '../workspaceMemberStatusUtils'
|
||||
|
||||
describe('workspaceMemberStatusUtils', () => {
|
||||
it('trimWorkspaceMemberStatusMessage enforces max length', () => {
|
||||
const long = 'x'.repeat(WORKSPACE_MEMBER_STATUS_MESSAGE_MAX + 50)
|
||||
expect(trimWorkspaceMemberStatusMessage(long).length).toBe(WORKSPACE_MEMBER_STATUS_MESSAGE_MAX)
|
||||
expect(trimWorkspaceMemberStatusMessage(' hi ')).toBe('hi')
|
||||
})
|
||||
|
||||
it('trimWorkspaceMemberStatusMessage keeps emoji intact at boundary', () => {
|
||||
const max = WORKSPACE_MEMBER_STATUS_MESSAGE_MAX
|
||||
const input = 'a'.repeat(max - 1) + '😀' + 'z'
|
||||
const out = trimWorkspaceMemberStatusMessage(input)
|
||||
expect(Array.from(out).length).toBe(max)
|
||||
expect(out.endsWith('😀')).toBe(true)
|
||||
})
|
||||
|
||||
it('isWorkspaceMemberStatusVisible respects clearAt and empty message', () => {
|
||||
const now = 1_000_000
|
||||
expect(isWorkspaceMemberStatusVisible(undefined, now)).toBe(false)
|
||||
expect(isWorkspaceMemberStatusVisible({ message: '', clearAt: now - 1 }, now)).toBe(false)
|
||||
expect(isWorkspaceMemberStatusVisible({ message: '' }, now)).toBe(false)
|
||||
expect(isWorkspaceMemberStatusVisible({ message: 'Hi' }, now)).toBe(true)
|
||||
expect(isWorkspaceMemberStatusVisible({ message: '🏖️' }, now)).toBe(true)
|
||||
})
|
||||
|
||||
it('getWorkspaceMemberStatusSubtitle returns trimmed message when visible', () => {
|
||||
const now = 10_000_000
|
||||
expect(getWorkspaceMemberStatusSubtitle({ message: '🏖️ Beach', clearAt: now + 1000 }, now)).toBe('🏖️ Beach')
|
||||
expect(getWorkspaceMemberStatusSubtitle({ message: 'Only text' }, now)).toBe('Only text')
|
||||
expect(getWorkspaceMemberStatusSubtitle({ message: '' }, now)).toBe(undefined)
|
||||
})
|
||||
|
||||
it('extractLeadingStatusEmoji takes first emoji sequence', () => {
|
||||
expect(extractLeadingStatusEmoji('🏖️ Holiday')).toBe('🏖️')
|
||||
expect(extractLeadingStatusEmoji('⏳ In a meeting')).toBe('⏳')
|
||||
expect(extractLeadingStatusEmoji('No emoji')).toBe(undefined)
|
||||
expect(extractLeadingStatusEmoji('')).toBe(undefined)
|
||||
expect(extractLeadingStatusEmoji('👋')).toBe('👋')
|
||||
})
|
||||
})
|
||||
@@ -217,6 +217,17 @@ export interface Translation extends Preference {
|
||||
dontTranslate: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent user status in workspace (e.g. vacation, short note).
|
||||
* @public
|
||||
*/
|
||||
export interface WorkspaceMemberStatus extends Doc {
|
||||
space: Ref<Space>
|
||||
user: AccountUuid
|
||||
message: string
|
||||
clearAt?: Timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -236,7 +247,8 @@ export const contactPlugin = plugin(contactId, {
|
||||
SocialIdentity: '' as Ref<Class<SocialIdentity>>,
|
||||
UserProfile: '' as Ref<MasterTag>,
|
||||
UserRole: '' as Ref<Class<UserRole>>,
|
||||
Translation: '' as Ref<Class<Translation>>
|
||||
Translation: '' as Ref<Class<Translation>>,
|
||||
WorkspaceMemberStatus: '' as Ref<Class<WorkspaceMemberStatus>>
|
||||
},
|
||||
mixin: {
|
||||
Employee: '' as Ref<Class<Employee>>
|
||||
@@ -264,7 +276,8 @@ export const contactPlugin = plugin(contactId, {
|
||||
PersonFilterValuePresenter: '' as AnyComponent,
|
||||
PersonIdFilter: '' as AnyComponent,
|
||||
AssigneePopup: '' as AnyComponent,
|
||||
EmployeePresenter: '' as AnyComponent
|
||||
EmployeePresenter: '' as AnyComponent,
|
||||
WorkspaceMemberStatusEditor: '' as AnyComponent
|
||||
},
|
||||
channelProvider: {
|
||||
Email: '' as Ref<ChannelProvider>,
|
||||
@@ -378,7 +391,8 @@ export const contactPlugin = plugin(contactId, {
|
||||
HereDescription: '' as IntlString,
|
||||
Guest: '' as IntlString,
|
||||
Deleted: '' as IntlString,
|
||||
Email: '' as IntlString
|
||||
Email: '' as IntlString,
|
||||
WorkspaceStatusNote: '' as IntlString
|
||||
},
|
||||
viewlet: {
|
||||
TableMember: '' as Ref<Viewlet>,
|
||||
@@ -429,3 +443,10 @@ export * from './types'
|
||||
export * from './utils'
|
||||
export * from './analytics'
|
||||
export * from './avatar'
|
||||
export {
|
||||
WORKSPACE_MEMBER_STATUS_MESSAGE_MAX,
|
||||
trimWorkspaceMemberStatusMessage,
|
||||
isWorkspaceMemberStatusVisible,
|
||||
getWorkspaceMemberStatusSubtitle,
|
||||
extractLeadingStatusEmoji
|
||||
} from './workspaceMemberStatusUtils'
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// Copyright © 2026 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.
|
||||
//
|
||||
|
||||
/** @public */
|
||||
export const WORKSPACE_MEMBER_STATUS_MESSAGE_MAX = 200
|
||||
|
||||
/**
|
||||
* Minimal shape for status helpers (see {@link WorkspaceMemberStatus}).
|
||||
*/
|
||||
interface WorkspaceMemberStatusLike {
|
||||
message: string
|
||||
clearAt?: number
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function trimWorkspaceMemberStatusMessage (message: string): string {
|
||||
const trimmed = message.trim()
|
||||
return Array.from(trimmed).slice(0, WORKSPACE_MEMBER_STATUS_MESSAGE_MAX).join('')
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function isWorkspaceMemberStatusVisible (
|
||||
s: WorkspaceMemberStatusLike | undefined,
|
||||
now: number = Date.now()
|
||||
): boolean {
|
||||
if (s === undefined) return false
|
||||
if (s.clearAt !== undefined && s.clearAt <= now) return false
|
||||
const m = s.message?.trim() ?? ''
|
||||
return m !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text line for member lists / tooltips (full stored message, including any leading emoji).
|
||||
* @public
|
||||
*/
|
||||
export function getWorkspaceMemberStatusSubtitle (
|
||||
s: WorkspaceMemberStatusLike | undefined,
|
||||
now: number = Date.now()
|
||||
): string | undefined {
|
||||
if (!isWorkspaceMemberStatusVisible(s, now)) return undefined
|
||||
return s?.message?.trim() ?? ''
|
||||
}
|
||||
|
||||
const EMOJI_HEAD = /^\p{Extended_Pictographic}/u
|
||||
|
||||
/**
|
||||
* Returns the first emoji sequence at the start of the status message (after trim), or undefined.
|
||||
* Handles variation selectors, skin tones, and ZWJ sequences (e.g. family emoji).
|
||||
* @public
|
||||
*/
|
||||
export function extractLeadingStatusEmoji (raw: string | undefined): string | undefined {
|
||||
const text = raw?.trim() ?? ''
|
||||
if (text === '') return undefined
|
||||
|
||||
try {
|
||||
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
const segments = [...seg.segment(text)].map((x) => x.segment)
|
||||
if (segments.length === 0) return undefined
|
||||
if (!EMOJI_HEAD.test(segments[0])) return undefined
|
||||
|
||||
let out = segments[0]
|
||||
let i = 1
|
||||
while (i < segments.length) {
|
||||
const s = segments[i]
|
||||
if (s === '\u200D' && i + 1 < segments.length && EMOJI_HEAD.test(segments[i + 1])) {
|
||||
out += s + segments[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (s === '\uFE0F' || /^[\u{1F3FB}-\u{1F3FF}]$/u.test(s)) {
|
||||
out += s
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
const m = text.match(/^(\p{Extended_Pictographic})/u)
|
||||
return m?.[1]
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ export interface TransformConfig {
|
||||
}
|
||||
|
||||
export interface RelationDefinition {
|
||||
/** When set, this relation applies only to documents of this class (or its subclasses). */
|
||||
sourceClass?: Ref<Class<Doc>>
|
||||
field: string
|
||||
class: Ref<Class<Doc>>
|
||||
direction?: 'forward' | 'inverse'
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"GuestSignUpDescription": "Umožňuje anonymním uživatelům připojit se k vašemu pracovnímu prostoru jako hosté s omezenými editačními právy",
|
||||
"GuestChannelsDescription": "Kanály, kde hosté mohou psát zprávy po připojení",
|
||||
"GuestChannelsArrayLabel": "Vybrat kanály",
|
||||
"GuestAutoJoinAvailableSpaces": "Dostupné prostory",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Každá karta aplikace má vlastní řádek „Dostupné prostory“ pro automatické připojení hostů; změny se projeví okamžitě.",
|
||||
"ManageIdentities": "Spravovat identity",
|
||||
"Release": "Uvolnění",
|
||||
"ReleaseSocialId": "Uvolnit sociální ID",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"GuestSignUpDescription": "Ermöglicht anonymen Benutzern den Beitritt zu Ihrem Arbeitsbereich als Gäste mit eingeschränkten Bearbeitungsrechten",
|
||||
"GuestChannelsDescription": "Kanäle, in denen Gäste nach dem Beitritt Nachrichten schreiben können",
|
||||
"GuestChannelsArrayLabel": "Kanäle auswählen",
|
||||
"GuestAutoJoinAvailableSpaces": "Verfügbare Bereiche",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Jede Anwendungskarte hat eine eigene Zeile „Verfügbare Bereiche“ für den automatischen Gästebeitritt; Änderungen gelten sofort.",
|
||||
"ManageIdentities": "Identitäten verwalten",
|
||||
"Release": "Freigeben",
|
||||
"ReleaseSocialId": "Social ID freigeben",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"GuestSignUpDescription": "Allows anonymous users to join your workspace as guests with limited editing rights",
|
||||
"GuestChannelsDescription": "Channels where guests can write messages after joining",
|
||||
"GuestChannelsArrayLabel": "Select channels",
|
||||
"GuestAutoJoinAvailableSpaces": "Available spaces",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Each application card has its own “Available spaces” row to pick where workspace guests are added on activation. Changes apply immediately.",
|
||||
"ManageIdentities": "Manage identities",
|
||||
"Release": "Release",
|
||||
"ReleaseSocialId": "Release social ID",
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"GuestSignUpDescription": "Permite a usuarios anónimos unirse a su espacio de trabajo como invitados con derechos de edición limitados",
|
||||
"GuestChannelsDescription": "Canales donde los invitados pueden escribir mensajes después de unirse",
|
||||
"GuestChannelsArrayLabel": "Seleccionar canales",
|
||||
"GuestAutoJoinAvailableSpaces": "Espacios disponibles",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Cada tarjeta de aplicación tiene su propia fila « Espacios disponibles » para la adhesión automática de invitados; los cambios son inmediatos.",
|
||||
"ManageIdentities": "Gestionar identidades",
|
||||
"Release": "Liberar",
|
||||
"ReleaseSocialId": "Liberar ID social",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"GuestSignUpDescription": "Permet aux utilisateurs anonymes de rejoindre votre espace de travail en tant qu'invités avec des droits d'édition limités",
|
||||
"GuestChannelsDescription": "Canaux où les invités peuvent écrire des messages après leur adhésion",
|
||||
"GuestChannelsArrayLabel": "Sélectionner des canaux",
|
||||
"GuestAutoJoinAvailableSpaces": "Espaces disponibles",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Chaque carte d’application a sa propre ligne « Espaces disponibles » pour l’adhésion automatique des invités ; les changements sont immédiats.",
|
||||
"ManageIdentities": "Gérer les identités",
|
||||
"Release": "Libération",
|
||||
"ReleaseSocialId": "Libérer l'ID social",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"GuestSignUpDescription": "Consente agli utenti anonimi di unirsi al tuo spazio di lavoro come ospiti con diritti di modifica limitati",
|
||||
"GuestChannelsDescription": "Canali in cui gli ospiti possono scrivere messaggi dopo essersi uniti",
|
||||
"GuestChannelsArrayLabel": "Seleziona canali",
|
||||
"GuestAutoJoinAvailableSpaces": "Spazi disponibili",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Ogni scheda applicazione ha la propria riga « Spazi disponibili » per l’ingresso automatico degli ospiti; le modifiche sono immediate.",
|
||||
"ManageIdentities": "Gestisci identità",
|
||||
"Release": "Rilascio",
|
||||
"ReleaseSocialId": "Rilascia ID social",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"GuestSignUpDescription": "匿名ユーザーがゲストとしてワークスペースに参加し、制限された編集権限を得られるようにします",
|
||||
"GuestChannelsDescription": "参加後、ゲストがメッセージを書き込めるチャンネル",
|
||||
"GuestChannelsArrayLabel": "チャンネルを選択",
|
||||
"GuestAutoJoinAvailableSpaces": "利用可能なスペース",
|
||||
"GuestAutoJoinAvailableSpacesHint": "各アプリのカードにゲスト自動参加用の「利用可能なスペース」行があります。変更はすぐに反映されます。",
|
||||
"ManageIdentities": "IDを管理",
|
||||
"Release": "リリース",
|
||||
"ReleaseSocialId": "ソーシャルIDをリリース",
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"GuestSignUpDescription": "Permite que usuários anônimos entrem em seu espaço de trabalho como convidados com direitos de edição limitados",
|
||||
"GuestChannelsDescription": "Canais onde convidados podem escrever mensagens após entrar",
|
||||
"GuestChannelsArrayLabel": "Selecionar canais",
|
||||
"GuestAutoJoinAvailableSpaces": "Espaços disponíveis",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Cada cartão de app tem sua própria linha « Espaços disponíveis » para adesão automática de convidados; as alterações são aplicadas na hora.",
|
||||
"ManageIdentities": "Gerenciar identidades",
|
||||
"Release": "Liberação",
|
||||
"ReleaseSocialId": "Liberar ID social",
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"GuestSignUpDescription": "Permite que usuários anônimos entrem em seu espaço de trabalho como convidados com direitos de edição limitados",
|
||||
"GuestChannelsDescription": "Canais onde convidados podem escrever mensagens após ingressar",
|
||||
"GuestChannelsArrayLabel": "Selecionar canais",
|
||||
"GuestAutoJoinAvailableSpaces": "Espaços disponíveis",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Cada cartão de aplicação tem a sua própria linha « Espaços disponíveis » para adesão automática de convidados; as alterações aplicam-se de imediato.",
|
||||
"ManageIdentities": "Gerenciar identidades",
|
||||
"Release": "Libertação",
|
||||
"ReleaseSocialId": "Liberar ID social",
|
||||
|
||||
@@ -179,6 +179,8 @@
|
||||
"GuestSignUpDescription": "Позволяет анонимным пользователям присоединяться к вашему рабочему пространству в качестве гостей с ограниченными правами редактирования",
|
||||
"GuestChannelsDescription": "Каналы, в которых гости могут писать сообщения после присоединения",
|
||||
"GuestChannelsArrayLabel": "Выбрать каналы",
|
||||
"GuestAutoJoinAvailableSpaces": "Доступные пространства",
|
||||
"GuestAutoJoinAvailableSpacesHint": "У каждого приложения ниже своя строка «Доступные пространства»: выберите, куда при активации добавлять гостей воркспейса. Изменения применяются сразу.",
|
||||
"ManageIdentities": "Управление идентификаторами",
|
||||
"Release": "Освободить",
|
||||
"ReleaseSocialId": "Освободить социальный ID",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"GuestSignUpDescription": "Anonim kullanıcıların çalışma alanınıza sınırlı düzenleme haklarıyla misafir olarak katılmasına izin verir",
|
||||
"GuestChannelsDescription": "Misafirlerin katıldıktan sonra mesaj yazabileceği kanallar",
|
||||
"GuestChannelsArrayLabel": "Kanalları seç",
|
||||
"GuestAutoJoinAvailableSpaces": "Kullanılabilir alanlar",
|
||||
"GuestAutoJoinAvailableSpacesHint": "Her uygulama kartının misafir otomatik katılımı için kendi « Kullanılabilir alanlar » satırı vardır; değişiklikler hemen uygulanır.",
|
||||
"ManageIdentities": "Kimlikleri yönet",
|
||||
"Release": "Serbest bırak",
|
||||
"ReleaseSocialId": "Sosyal ID'yi serbest bırak",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"GuestSignUpDescription": "允许匿名用户以访客身份加入您的工作区,获得有限的编辑权限",
|
||||
"GuestChannelsDescription": "访客加入后可以发送消息的频道",
|
||||
"GuestChannelsArrayLabel": "选择频道",
|
||||
"GuestAutoJoinAvailableSpaces": "可用空间",
|
||||
"GuestAutoJoinAvailableSpacesHint": "每个应用卡片都有各自的「可用空间」行,用于选择在激活时添加工作区访客的位置;更改会立即生效。",
|
||||
"ManageIdentities": "管理身份",
|
||||
"Release": "释放",
|
||||
"ReleaseSocialId": "释放社交ID",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 { Analytics } from '@hcengineering/analytics'
|
||||
import {
|
||||
AccountRole,
|
||||
setWorkspaceGuestAutoJoinRoles,
|
||||
type ModulePermissionGroup,
|
||||
type Space
|
||||
} from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { DropdownLabels, type DropdownTextItem } from '@hcengineering/ui'
|
||||
import settingsRes from '../plugin'
|
||||
|
||||
export let group: ModulePermissionGroup
|
||||
export let disabled = false
|
||||
|
||||
const client = getClient()
|
||||
|
||||
let loading = true
|
||||
let saving = false
|
||||
let loadedGroupId: string | undefined
|
||||
let dropdownItems: DropdownTextItem[] = []
|
||||
let selectedKeys: string[] = []
|
||||
let persistedSelectedKeys: string[] = []
|
||||
let docById: Map<string, Space> = new Map<string, Space>()
|
||||
|
||||
function normalizeAutoJoinForRoles (roles: AccountRole[]): AccountRole[] | undefined {
|
||||
return roles.length > 0 ? [...roles] : undefined
|
||||
}
|
||||
|
||||
async function loadSpaces (): Promise<void> {
|
||||
if (group.spaceClass === undefined) {
|
||||
dropdownItems = []
|
||||
selectedKeys = []
|
||||
persistedSelectedKeys = []
|
||||
docById = new Map<string, Space>()
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
const res = await client.findAll(group.spaceClass, { archived: false })
|
||||
docById = new Map(res.map((d) => [d._id, d as Space]))
|
||||
const docs = [...docById.values()]
|
||||
dropdownItems = docs.map((d) => ({ id: d._id, label: d.name })).sort((a, b) => a.label.localeCompare(b.label))
|
||||
selectedKeys = docs
|
||||
.filter((d) => d.autoJoinForRoles?.includes(AccountRole.Guest) === true)
|
||||
.map((d) => d._id)
|
||||
.sort()
|
||||
persistedSelectedKeys = [...selectedKeys]
|
||||
} catch (err) {
|
||||
Analytics.handleError(err as Error)
|
||||
dropdownItems = []
|
||||
selectedKeys = []
|
||||
persistedSelectedKeys = []
|
||||
docById = new Map<string, Space>()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelected (e: CustomEvent<string[]>): Promise<void> {
|
||||
const nextKeys = new Set(e.detail)
|
||||
const prevKeys = new Set(persistedSelectedKeys)
|
||||
const toEnable = [...nextKeys].filter((k) => !prevKeys.has(k))
|
||||
const toDisable = [...prevKeys].filter((k) => !nextKeys.has(k))
|
||||
|
||||
if (toEnable.length === 0 && toDisable.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
saving = true
|
||||
try {
|
||||
const ops: Array<Promise<unknown>> = []
|
||||
for (const key of toEnable) {
|
||||
const doc = docById.get(key)
|
||||
if (doc === undefined) continue
|
||||
const roles = setWorkspaceGuestAutoJoinRoles(doc.autoJoinForRoles, true)
|
||||
const autoJoinForRoles = normalizeAutoJoinForRoles(roles)
|
||||
if (autoJoinForRoles === undefined) {
|
||||
ops.push(client.update(doc, { $unset: { autoJoinForRoles: true } } as any))
|
||||
} else {
|
||||
ops.push(client.diffUpdate(doc, { autoJoinForRoles }))
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of toDisable) {
|
||||
const doc = docById.get(key)
|
||||
if (doc === undefined) continue
|
||||
const roles = setWorkspaceGuestAutoJoinRoles(doc.autoJoinForRoles, false)
|
||||
const autoJoinForRoles = normalizeAutoJoinForRoles(roles)
|
||||
if (autoJoinForRoles === undefined) {
|
||||
ops.push(client.update(doc, { $unset: { autoJoinForRoles: true } } as any))
|
||||
} else {
|
||||
ops.push(client.diffUpdate(doc, { autoJoinForRoles }))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(ops)
|
||||
selectedKeys = [...nextKeys].sort()
|
||||
persistedSelectedKeys = [...selectedKeys]
|
||||
} catch (err) {
|
||||
Analytics.handleError(err as Error)
|
||||
selectedKeys = [...persistedSelectedKeys]
|
||||
await loadSpaces()
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
$: if (group._id !== loadedGroupId) {
|
||||
loadedGroupId = group._id
|
||||
void loadSpaces()
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownLabels
|
||||
multiselect
|
||||
autoSelect={false}
|
||||
items={dropdownItems}
|
||||
selected={[...(selectedKeys ?? [])]}
|
||||
label={settingsRes.string.GuestAutoJoinAvailableSpaces}
|
||||
showDropdownIcon
|
||||
kind={'no-border'}
|
||||
size={'large'}
|
||||
width={'min(18rem, 100%)'}
|
||||
disabled={disabled || loading || saving || dropdownItems.length === 0}
|
||||
loading={saving}
|
||||
on:selected={handleSelected}
|
||||
/>
|
||||
@@ -48,6 +48,7 @@
|
||||
} from '@hcengineering/ui'
|
||||
import setting from '@hcengineering/setting'
|
||||
import { onMount } from 'svelte'
|
||||
import AvailableSpacesInput from './AvailableSpacesInput.svelte'
|
||||
import settingsRes from '../plugin'
|
||||
|
||||
let loadingSettings = true
|
||||
@@ -244,6 +245,14 @@
|
||||
allowGuestSignUp = e.detail
|
||||
}
|
||||
|
||||
function onReadonlyGuestsToggle (e: CustomEvent<boolean>): void {
|
||||
void handleToggleReadonlyAccess(e)
|
||||
}
|
||||
|
||||
function onGuestSignUpToggle (e: CustomEvent<boolean>): void {
|
||||
void handleToggleGuestSignUp(e)
|
||||
}
|
||||
|
||||
async function onAllowedCardsChange (value: Ref<Card>[]): Promise<void> {
|
||||
if (existingGuestChatSettings === undefined) {
|
||||
await client.createDoc(communication.class.GuestCommunicationSettings, core.space.Workspace, {
|
||||
@@ -317,12 +326,7 @@
|
||||
<Label label={settingsRes.string.GuestAccessDescription} />
|
||||
</div>
|
||||
<div class="guestAccessRow-toggleCell">
|
||||
<Toggle
|
||||
on={allowReadOnlyGuests}
|
||||
on:change={(e) => {
|
||||
void handleToggleReadonlyAccess(e)
|
||||
}}
|
||||
/>
|
||||
<Toggle on={allowReadOnlyGuests} on:change={onReadonlyGuestsToggle} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="guestAccessRow">
|
||||
@@ -330,13 +334,7 @@
|
||||
<Label label={settingsRes.string.GuestSignUpDescription} />
|
||||
</div>
|
||||
<div class="guestAccessRow-toggleCell">
|
||||
<Toggle
|
||||
disabled={!allowReadOnlyGuests}
|
||||
on={allowGuestSignUp}
|
||||
on:change={(e) => {
|
||||
void handleToggleGuestSignUp(e)
|
||||
}}
|
||||
/>
|
||||
<Toggle disabled={!allowReadOnlyGuests} on={allowGuestSignUp} on:change={onGuestSignUpToggle} />
|
||||
</div>
|
||||
</div>
|
||||
{#if communicationApiEnabled}
|
||||
@@ -376,6 +374,14 @@
|
||||
<Label label={setting.string.GuestPermissionsApplicationPermissionsHint} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if guestPermissionsTab === 'guest'}
|
||||
<div class="sectionHint">
|
||||
<Label label={core.string.AutoJoinGuestsDescr} />
|
||||
</div>
|
||||
<div class="sectionHint">
|
||||
<Label label={settingsRes.string.GuestAutoJoinAvailableSpacesHint} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="cardStack" class:cardStack-readonly={anonymousModulePermissionsReadOnly}>
|
||||
@@ -383,10 +389,15 @@
|
||||
{@const app = getApplication(group.application)}
|
||||
{@const moduleOn = isModuleEnabled(group)}
|
||||
{@const permissionCount = (group.permissions ?? []).length}
|
||||
{@const hasGuestAutoJoinRow =
|
||||
guestPermissionsTab === 'guest' &&
|
||||
group.role === AccountRole.Guest &&
|
||||
group.spaceClass !== undefined}
|
||||
{@const hasPermissionRowsBlock = permissionCount > 0 || hasGuestAutoJoinRow}
|
||||
<div class="permissionModuleCard" class:permissionModuleCard-off={!moduleOn}>
|
||||
<div
|
||||
class="permissionModuleCard-header"
|
||||
class:permissionModuleCard-headerOnly={permissionCount === 0}
|
||||
class:permissionModuleCard-headerOnly={!hasPermissionRowsBlock}
|
||||
>
|
||||
<div class="permissionModuleCard-headerMain">
|
||||
{#if app}
|
||||
@@ -411,22 +422,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if permissionCount > 0}
|
||||
{#if hasPermissionRowsBlock}
|
||||
<div class="permissionRows">
|
||||
{#each group.permissions ?? [] as permissionId}
|
||||
<div class="permissionRow">
|
||||
<div class="permissionRow-label">
|
||||
<Label label={getPermissionLabel(permissionId)} />
|
||||
{#if permissionCount > 0}
|
||||
{#each group.permissions ?? [] as permissionId}
|
||||
<div class="permissionRow">
|
||||
<div class="permissionRow-label">
|
||||
<Label label={getPermissionLabel(permissionId)} />
|
||||
</div>
|
||||
<div class="permissionRow-toggleCell">
|
||||
<Toggle
|
||||
disabled={!moduleOn || anonymousModulePermissionsReadOnly}
|
||||
on={isPermissionActive(group, permissionId)}
|
||||
on:change={handlePermissionToggle(group, permissionId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="permissionRow-toggleCell">
|
||||
<Toggle
|
||||
{/each}
|
||||
{/if}
|
||||
{#if hasGuestAutoJoinRow}
|
||||
<div class="permissionRow permissionRow--guestSpaces">
|
||||
<div class="permissionRow-label">
|
||||
<Label label={settingsRes.string.GuestAutoJoinAvailableSpaces} />
|
||||
</div>
|
||||
<div class="permissionRow-editorCell">
|
||||
<AvailableSpacesInput
|
||||
{group}
|
||||
disabled={!moduleOn || anonymousModulePermissionsReadOnly}
|
||||
on={isPermissionActive(group, permissionId)}
|
||||
on:change={handlePermissionToggle(group, permissionId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -644,6 +670,24 @@
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
|
||||
.permissionRow--guestSpaces {
|
||||
align-items: start;
|
||||
min-height: auto;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8rem, max-content);
|
||||
}
|
||||
|
||||
.permissionRow--guestSpaces .permissionRow-editorCell {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
width: max-content;
|
||||
max-width: min(18rem, calc(100vw - 4rem));
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-halfcontent-color);
|
||||
|
||||
@@ -137,6 +137,8 @@ export default mergeIds(settingId, setting, {
|
||||
GuestSignUpDescription: '' as IntlString,
|
||||
GuestChannelsDescription: '' as IntlString,
|
||||
GuestChannelsArrayLabel: '' as IntlString,
|
||||
GuestAutoJoinAvailableSpaces: '' as IntlString,
|
||||
GuestAutoJoinAvailableSpacesHint: '' as IntlString,
|
||||
ApiAccess: '' as IntlString,
|
||||
ApiToken: '' as IntlString,
|
||||
GenerateApiToken: '' as IntlString,
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import contact, { formatName, getCurrentEmployee } from '@hcengineering/contact'
|
||||
import { myEmployeeStore } from '@hcengineering/contact-resources'
|
||||
import contact, { formatName, getCurrentEmployee, isWorkspaceMemberStatusVisible } from '@hcengineering/contact'
|
||||
import {
|
||||
myEmployeeStore,
|
||||
loadWorkspaceMemberStatuses,
|
||||
workspaceMemberStatusByAccountStore
|
||||
} from '@hcengineering/contact-resources'
|
||||
import contactResources from '@hcengineering/contact-resources/src/plugin'
|
||||
import core, { AccountRole, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
|
||||
import rating, { type PersonRating } from '@hcengineering/rating'
|
||||
import login, { loginId } from '@hcengineering/login'
|
||||
@@ -60,6 +65,7 @@
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
loadWorkspaceMemberStatuses()
|
||||
void getResource(setting.function.HasRoleCapability).then((checkCapability) => {
|
||||
if (checkCapability != null) {
|
||||
void checkCapability(RoleCapability.GenerateInviteLink).then((v: boolean) => {
|
||||
@@ -70,6 +76,10 @@
|
||||
})
|
||||
|
||||
$: person = $myEmployeeStore
|
||||
$: ownWorkspaceStatus = $workspaceMemberStatusByAccountStore.get(account.uuid)
|
||||
$: workspaceStatusActionLabel = isWorkspaceMemberStatusVisible(ownWorkspaceStatus)
|
||||
? contactResources.string.WorkspaceStatusUpdateYour
|
||||
: contactResources.string.WorkspaceStatusSetYour
|
||||
|
||||
function selectCategory (sp?: SettingsCategory): void {
|
||||
closePopup()
|
||||
@@ -142,6 +152,16 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
if (hasAccountRole(account, AccountRole.User)) {
|
||||
actions.push({
|
||||
icon: contact.icon.User,
|
||||
label: workspaceStatusActionLabel,
|
||||
action: async () => {
|
||||
closePopup()
|
||||
showPopup(contact.component.WorkspaceMemberStatusEditor, {}, 'middle')
|
||||
}
|
||||
})
|
||||
}
|
||||
actions.push(...getMenu(items, ['main']))
|
||||
if (hasAccountRole(account, AccountRole.User) && !isDisabled('invites') && canGenerateInviteLink) {
|
||||
actions.push({
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
//
|
||||
// Copyright © 2026 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 {
|
||||
Class,
|
||||
type Doc,
|
||||
type Hierarchy,
|
||||
type LowLevelStorage,
|
||||
MeasureMetricsContext,
|
||||
type Ref,
|
||||
type Space
|
||||
} from '@hcengineering/core'
|
||||
import { RelationExporter } from '../workspace/relation-exporter'
|
||||
import type { ExportState, RelationDefinition } from '../workspace/types'
|
||||
|
||||
const spaceId = '69286daacb49b698d3ea2c51' as Ref<Space>
|
||||
const classParent = 'test:class:ParentDoc' as Ref<Class<Doc>>
|
||||
const classChild = 'test:class:ChildDoc' as Ref<Class<Doc>>
|
||||
const classUnrelated = 'test:class:UnrelatedDoc' as Ref<Class<Doc>>
|
||||
const classTarget = 'test:class:TargetDoc' as Ref<Class<Doc>>
|
||||
|
||||
function doc (id: string, _class: Ref<Class<Doc>>, extra: Record<string, any> = {}): Doc {
|
||||
return {
|
||||
_id: id as Ref<Doc>,
|
||||
_class,
|
||||
space: spaceId,
|
||||
modifiedOn: 0,
|
||||
modifiedBy: 'test:account:user' as any,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function hierarchyWithDerivation (childDerivesParent: boolean): Hierarchy {
|
||||
return {
|
||||
findDomain: jest.fn((classRef: Ref<Class<Doc>>) => `domain:${String(classRef)}`),
|
||||
isDerived: jest.fn((cls: Ref<Class<Doc>>, base: Ref<Class<Doc>>) => {
|
||||
if (cls === base) return true
|
||||
if (childDerivesParent && cls === classChild && base === classParent) return true
|
||||
return false
|
||||
}),
|
||||
getAllAttributes: jest.fn(() => new Map()),
|
||||
isMixin: jest.fn(() => false),
|
||||
hasMixin: jest.fn(),
|
||||
getClass: jest.fn(() => ({ label: 'Test' }))
|
||||
} as unknown as Hierarchy
|
||||
}
|
||||
|
||||
function lowLevelForDocs (...docs: Doc[]): LowLevelStorage {
|
||||
const byDomain = new Map<string, Doc[]>()
|
||||
for (const d of docs) {
|
||||
const domain = `domain:${String(d._class)}`
|
||||
const list = byDomain.get(domain) ?? []
|
||||
list.push(d)
|
||||
byDomain.set(domain, list)
|
||||
}
|
||||
return {
|
||||
rawFindAll: jest.fn(async <T extends Doc>(_domain: string, query: any): Promise<T[]> => {
|
||||
const domainDocs = byDomain.get(_domain) ?? []
|
||||
return domainDocs.filter((d) => {
|
||||
if (query._id !== undefined && d._id !== query._id) return false
|
||||
for (const [key, val] of Object.entries(query)) {
|
||||
if (key.startsWith('_')) continue
|
||||
if ((d as any)[key] !== val) return false
|
||||
}
|
||||
return true
|
||||
}) as T[]
|
||||
})
|
||||
} as unknown as LowLevelStorage
|
||||
}
|
||||
|
||||
function createExporter (exportDocument: jest.Mock): { exporter: RelationExporter, state: ExportState } {
|
||||
const state: ExportState = {
|
||||
idMapping: new Map(),
|
||||
spaceMapping: new Map(),
|
||||
processingDocs: new Set(),
|
||||
uniqueFieldValues: new Map()
|
||||
}
|
||||
const ctx = new MeasureMetricsContext('relation-exporter-test', {})
|
||||
const exporter = new RelationExporter(ctx, state, exportDocument as any)
|
||||
return { exporter, state }
|
||||
}
|
||||
|
||||
describe('RelationExporter sourceClass', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('runs forward relation when doc class is derived from sourceClass', async () => {
|
||||
const parentId = '69286dc0cb49b698d3ea2c95' as Ref<Doc>
|
||||
const parent = doc(parentId, classTarget, { name: 'parent' })
|
||||
const child = doc('69286dc1cb49b698d3ea2c98', classChild, { folder: parentId })
|
||||
|
||||
const exportDocument = jest.fn().mockResolvedValue(true)
|
||||
const { exporter } = createExporter(exportDocument)
|
||||
const hierarchy = hierarchyWithDerivation(true)
|
||||
const lowLevel = lowLevelForDocs(parent)
|
||||
|
||||
const relations: RelationDefinition[] = [
|
||||
{
|
||||
sourceClass: classParent,
|
||||
field: 'folder',
|
||||
class: classTarget,
|
||||
direction: 'forward'
|
||||
}
|
||||
]
|
||||
|
||||
await exporter.exportForwardRelations(child, relations, 'duplicate', true, hierarchy, lowLevel)
|
||||
|
||||
expect(exportDocument).toHaveBeenCalledTimes(1)
|
||||
expect(exportDocument).toHaveBeenCalledWith(
|
||||
parent,
|
||||
'duplicate',
|
||||
true,
|
||||
hierarchy,
|
||||
lowLevel,
|
||||
expect.any(Map),
|
||||
relations
|
||||
)
|
||||
})
|
||||
|
||||
it('skips forward relation when doc class is not derived from sourceClass', async () => {
|
||||
const parentId = '69286dc0cb49b698d3ea2c95' as Ref<Doc>
|
||||
const parent = doc(parentId, classTarget, {})
|
||||
const unrelated = doc('69286dc1cb49b698d3ea2c98', classUnrelated, { folder: parentId })
|
||||
|
||||
const exportDocument = jest.fn().mockResolvedValue(true)
|
||||
const { exporter } = createExporter(exportDocument)
|
||||
const hierarchy = hierarchyWithDerivation(true)
|
||||
const lowLevel = lowLevelForDocs(parent)
|
||||
|
||||
const relations: RelationDefinition[] = [
|
||||
{
|
||||
sourceClass: classParent,
|
||||
field: 'folder',
|
||||
class: classTarget,
|
||||
direction: 'forward'
|
||||
}
|
||||
]
|
||||
|
||||
await exporter.exportForwardRelations(unrelated, relations, 'duplicate', true, hierarchy, lowLevel)
|
||||
|
||||
expect(exportDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs forward relation without sourceClass for any doc (backward compatible)', async () => {
|
||||
const refId = '69286dc0cb49b698d3ea2c95' as Ref<Doc>
|
||||
const target = doc(refId, classTarget, {})
|
||||
const anyDoc = doc('69286dc1cb49b698d3ea2c98', classUnrelated, { link: refId })
|
||||
|
||||
const exportDocument = jest.fn().mockResolvedValue(true)
|
||||
const { exporter } = createExporter(exportDocument)
|
||||
const hierarchy = hierarchyWithDerivation(false)
|
||||
jest.mocked(hierarchy.findDomain).mockImplementation((c) => `domain:${String(c)}` as any)
|
||||
const lowLevel = lowLevelForDocs(target)
|
||||
|
||||
const relations: RelationDefinition[] = [{ field: 'link', class: classTarget, direction: 'forward' }]
|
||||
|
||||
await exporter.exportForwardRelations(anyDoc, relations, 'duplicate', true, hierarchy, lowLevel)
|
||||
|
||||
expect(exportDocument).toHaveBeenCalledTimes(1)
|
||||
expect(exportDocument).toHaveBeenCalledWith(
|
||||
target,
|
||||
'duplicate',
|
||||
true,
|
||||
hierarchy,
|
||||
lowLevel,
|
||||
expect.any(Map),
|
||||
relations
|
||||
)
|
||||
})
|
||||
|
||||
it('runs inverse relation when doc class is derived from sourceClass', async () => {
|
||||
const anchorId = '69286dc0cb49b698d3ea2c95' as Ref<Doc>
|
||||
const anchor = doc(anchorId, classChild, {})
|
||||
const pointing = doc('69286dc1cb49b698d3ea2c98', classTarget, { owner: anchorId })
|
||||
|
||||
const exportDocument = jest.fn().mockResolvedValue(true)
|
||||
const { exporter } = createExporter(exportDocument)
|
||||
const hierarchy = hierarchyWithDerivation(true)
|
||||
jest.mocked(hierarchy.findDomain).mockReturnValue(`domain:${String(classTarget)}` as any)
|
||||
jest.mocked(hierarchy.isDerived).mockImplementation((cls, base) => {
|
||||
if (cls === base) return true
|
||||
if (cls === classChild && base === classParent) return true
|
||||
if (cls === classTarget && base === classTarget) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const lowLevel = lowLevelForDocs(pointing)
|
||||
|
||||
const relations: RelationDefinition[] = [
|
||||
{
|
||||
sourceClass: classParent,
|
||||
field: 'owner',
|
||||
class: classTarget,
|
||||
direction: 'inverse'
|
||||
}
|
||||
]
|
||||
|
||||
await exporter.exportInverseRelations(anchor, relations, 'duplicate', true, hierarchy, lowLevel)
|
||||
|
||||
expect(exportDocument).toHaveBeenCalledTimes(1)
|
||||
expect(exportDocument).toHaveBeenCalledWith(
|
||||
pointing,
|
||||
'duplicate',
|
||||
true,
|
||||
hierarchy,
|
||||
lowLevel,
|
||||
expect.any(Map),
|
||||
relations
|
||||
)
|
||||
})
|
||||
|
||||
it('skips inverse relation when doc class is not derived from sourceClass', async () => {
|
||||
const anchor = doc('69286dc0cb49b698d3ea2c95', classUnrelated, {})
|
||||
const pointing = doc('69286dc1cb49b698d3ea2c98', classTarget, { owner: anchor._id })
|
||||
|
||||
const exportDocument = jest.fn().mockResolvedValue(true)
|
||||
const { exporter } = createExporter(exportDocument)
|
||||
const hierarchy = hierarchyWithDerivation(true)
|
||||
jest.mocked(hierarchy.findDomain).mockReturnValue(`domain:${String(classTarget)}` as any)
|
||||
|
||||
const lowLevel = lowLevelForDocs(pointing)
|
||||
|
||||
const relations: RelationDefinition[] = [
|
||||
{
|
||||
sourceClass: classParent,
|
||||
field: 'owner',
|
||||
class: classTarget,
|
||||
direction: 'inverse'
|
||||
}
|
||||
]
|
||||
|
||||
await exporter.exportInverseRelations(anchor, relations, 'duplicate', true, hierarchy, lowLevel)
|
||||
|
||||
expect(exportDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -126,6 +126,7 @@ const extractQueryToken = (queryParams: any): string | null => {
|
||||
}
|
||||
|
||||
interface RelationPayloadEntry {
|
||||
sourceClass?: Ref<Class<Doc>>
|
||||
field?: string
|
||||
class?: Ref<Class<Doc>>
|
||||
direction?: 'forward' | 'inverse'
|
||||
@@ -154,6 +155,7 @@ const normalizeRelations = (input: unknown): RelationDefinition[] | undefined =>
|
||||
}
|
||||
|
||||
result.push({
|
||||
...(item.sourceClass !== undefined ? { sourceClass: item.sourceClass } : {}),
|
||||
field: item.field,
|
||||
class: item.class,
|
||||
direction: item.direction ?? 'forward'
|
||||
@@ -180,7 +182,12 @@ const normalizeRelations = (input: unknown): RelationDefinition[] | undefined =>
|
||||
}
|
||||
|
||||
const field = typeof value.field === 'string' ? value.field : key
|
||||
result.push({ field, class: value.class, direction: value.direction ?? 'forward' })
|
||||
result.push({
|
||||
...(value.sourceClass !== undefined ? { sourceClass: value.sourceClass } : {}),
|
||||
field,
|
||||
class: value.class,
|
||||
direction: value.direction ?? 'forward'
|
||||
})
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined
|
||||
|
||||
@@ -154,6 +154,10 @@ export class RelationExporter {
|
||||
sourceLowLevel: LowLevelStorage,
|
||||
relations: RelationDefinition[]
|
||||
): Promise<void> {
|
||||
if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = (doc as any)[relation.field]
|
||||
if (value === undefined || value === null) {
|
||||
return
|
||||
@@ -195,6 +199,10 @@ export class RelationExporter {
|
||||
sourceLowLevel: LowLevelStorage,
|
||||
relations: RelationDefinition[]
|
||||
): Promise<void> {
|
||||
if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) {
|
||||
return
|
||||
}
|
||||
|
||||
const domain = sourceHierarchy.findDomain(relation.class)
|
||||
if (domain === undefined) {
|
||||
this.context.warn(`Domain not found for relation class ${relation.class}`)
|
||||
|
||||
@@ -27,6 +27,8 @@ import { type Pipeline } from '@hcengineering/server-core'
|
||||
export type PipelineFactory = (ctx: MeasureContext, workspace: WorkspaceIds) => Promise<Pipeline>
|
||||
|
||||
export interface RelationDefinition {
|
||||
/** When set, this relation applies only to documents of this class (or its subclasses). */
|
||||
sourceClass?: Ref<Class<Doc>>
|
||||
field: string
|
||||
class: Ref<Class<Doc>>
|
||||
direction?: 'forward' | 'inverse'
|
||||
|
||||
@@ -184,6 +184,7 @@ export class CrossWorkspaceExporter {
|
||||
if (resolvedRelations.length === 0) {
|
||||
const relations = await sourcePipeline.findAll(this.context, core.class.RelationMetadata, {})
|
||||
resolvedRelations = relations.map((doc) => ({
|
||||
sourceClass: doc.sourceClass,
|
||||
field: doc.field,
|
||||
class: doc.targetClass,
|
||||
direction: (doc.direction ?? 'forward') as 'forward' | 'inverse'
|
||||
|
||||
Reference in New Issue
Block a user