Add workbench tabs (#6788)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-10-02 22:49:49 +04:00 committed by GitHub
parent ce2621d472
commit b5fed4879f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 794 additions and 69 deletions

View File

@ -64,6 +64,7 @@ export function createModel (builder: Builder): void {
core.space.Model,
{
label: chunter.string.ApplicationLabelChunter,
locationDataResolver: chunter.function.LocationDataResolver,
icon: chunter.icon.Chunter,
alias: chunterId,
hidden: false,
@ -87,6 +88,11 @@ export function createModel (builder: Builder): void {
chunter.ids.ChatWidget
)
builder.createDoc(presentation.class.ComponentPointExtension, core.space.Model, {
extension: workbench.extensions.WorkbenchTabExtensions,
component: chunter.component.WorkbenchTabExtension
})
const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage]
spaceClasses.forEach((spaceClass) => {

View File

@ -22,7 +22,7 @@ import type { IntlString, Resource } from '@hcengineering/platform'
import { mergeIds } from '@hcengineering/platform'
import type { AnyComponent, Location } from '@hcengineering/ui/src/types'
import type { Action, ActionCategory, ViewAction, Viewlet, ViewletDescriptor } from '@hcengineering/view'
import { type WidgetTab } from '@hcengineering/workbench'
import { type WidgetTab, type LocationData } from '@hcengineering/workbench'
export default mergeIds(chunterId, chunter, {
component: {
@ -34,7 +34,8 @@ export default mergeIds(chunterId, chunter, {
ChatWidgetTab: '' as AnyComponent,
ChatMessageNotificationLabel: '' as AnyComponent,
ThreadNotificationPresenter: '' as AnyComponent,
JoinChannelNotificationPresenter: '' as AnyComponent
JoinChannelNotificationPresenter: '' as AnyComponent,
WorkbenchTabExtension: '' as AnyComponent
},
action: {
MarkCommentUnread: '' as Ref<Action>,
@ -108,7 +109,8 @@ export default mergeIds(chunterId, chunter, {
ReplyToThread: '' as Resource<(doc: ActivityMessage, event: MouseEvent) => Promise<void>>,
CanReplyToThread: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
GetMessageLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
CloseChatWidgetTab: '' as Resource<(tab: WidgetTab) => Promise<void>>
CloseChatWidgetTab: '' as Resource<(tab: WidgetTab) => Promise<void>>,
LocationDataResolver: '' as Resource<(loc: Location) => Promise<LocationData>>
},
filter: {
ChatMessagesFilter: '' as Resource<(message: ActivityMessage, _class?: Ref<Doc>) => boolean>

View File

@ -38,6 +38,7 @@
"@hcengineering/model-attachment": "^0.6.0",
"@hcengineering/model-chunter": "^0.6.0",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-guest": "^0.6.0",
"@hcengineering/model-notification": "^0.6.0",
"@hcengineering/model-presentation": "^0.6.0",
"@hcengineering/model-view": "^0.6.0",
@ -48,7 +49,7 @@
"@hcengineering/templates": "^0.6.11",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13",
"@hcengineering/model-guest": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"cross-fetch": "^3.1.5"
}
}

View File

@ -324,6 +324,7 @@ export function createModel (builder: Builder): void {
hidden: false,
// component: contact.component.ContactsTabs,
locationResolver: contact.resolver.Location,
locationDataResolver: contact.resolver.LocationData,
navigatorModel: {
spaces: [],
specials: [

View File

@ -21,9 +21,10 @@ import { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineer
import { type NotificationGroup } from '@hcengineering/notification'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type TemplateFieldFunc } from '@hcengineering/templates'
import type { AnyComponent } from '@hcengineering/ui/src/types'
import { type AnyComponent, type Location } from '@hcengineering/ui/src/types'
import { type Action, type ActionCategory, type ViewAction } from '@hcengineering/view'
import { type ChatMessageViewlet } from '@hcengineering/chunter'
import { type LocationData } from '@hcengineering/workbench'
export default mergeIds(contactId, contact, {
activity: {
@ -63,7 +64,6 @@ export default mergeIds(contactId, contact, {
ChannelIcon: '' as AnyComponent
},
string: {
Persons: '' as IntlString,
SearchEmployee: '' as IntlString,
SearchPerson: '' as IntlString,
SearchOrganization: '' as IntlString,
@ -97,7 +97,6 @@ export default mergeIds(contactId, contact, {
ConfigLabel: '' as IntlString,
ConfigDescription: '' as IntlString,
Employees: '' as IntlString,
People: '' as IntlString
},
completion: {
@ -142,5 +141,8 @@ export default mergeIds(contactId, contact, {
ChannelIdentifierProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
SetPersonStore: '' as Resource<(manager: DocManager<any>) => void>,
PersonFilterFunction: '' as Resource<(doc: Doc, target: Doc) => boolean>
},
resolver: {
LocationData: '' as Resource<(loc: Location) => Promise<LocationData>>
}
})

View File

@ -30,13 +30,14 @@
"dependencies": {
"@hcengineering/core": "^0.6.32",
"@hcengineering/model": "^0.6.11",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/model-preference": "^0.6.0",
"@hcengineering/model-presentation": "^0.6.0",
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13",
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/workbench-resources": "^0.6.1",
"@hcengineering/model-preference": "^0.6.0"
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/workbench-resources": "^0.6.1"
}
}

View File

@ -30,10 +30,12 @@ import type {
WidgetPreference,
WidgetTab,
WidgetType,
SidebarEvent
SidebarEvent,
WorkbenchTab
} from '@hcengineering/workbench'
import { type AnyComponent } from '@hcengineering/ui'
import core, { TClass, TDoc, TTx } from '@hcengineering/model-core'
import presentation from '@hcengineering/model-presentation'
import workbench from './plugin'
@ -98,6 +100,13 @@ export class TTxSidebarEvent extends TTx implements TxSidebarEvent {
params!: Record<string, any>
}
@Model(workbench.class.WorkbenchTab, preference.class.Preference)
@UX(workbench.string.Tab)
export class TWorkbenchTab extends TPreference implements WorkbenchTab {
location!: string
isPinned!: boolean
}
export function createModel (builder: Builder): void {
builder.createModel(
TApplication,
@ -106,7 +115,8 @@ export function createModel (builder: Builder): void {
TApplicationNavModel,
TWidget,
TWidgetPreference,
TTxSidebarEvent
TTxSidebarEvent,
TWorkbenchTab
)
builder.mixin(workbench.class.Application, core.class.Class, view.mixin.ObjectPresenter, {
@ -133,6 +143,52 @@ export function createModel (builder: Builder): void {
mode: ['workbench']
}
})
createAction(builder, {
action: workbench.actionImpl.PinTab,
label: view.string.Pin,
icon: view.icon.Pin,
input: 'focus',
category: workbench.category.Workbench,
target: workbench.class.WorkbenchTab,
query: {
isPinned: false
},
context: {
mode: 'context',
group: 'edit'
}
})
createAction(builder, {
action: workbench.actionImpl.UnpinTab,
label: view.string.Unpin,
icon: view.icon.Pin,
input: 'focus',
category: workbench.category.Workbench,
target: workbench.class.WorkbenchTab,
query: {
isPinned: true
},
context: {
mode: 'context',
group: 'edit'
}
})
createAction(builder, {
action: workbench.actionImpl.CloseTab,
label: presentation.string.Close,
icon: view.icon.Delete,
input: 'focus',
category: workbench.category.Workbench,
target: workbench.class.WorkbenchTab,
visibilityTester: workbench.function.CanCloseTab,
context: {
mode: 'context',
group: 'edit'
}
})
}
export default workbench

View File

@ -13,11 +13,12 @@
// limitations under the License.
//
import { type Doc, type Space } from '@hcengineering/core'
import { type Doc, type Ref, type Space } from '@hcengineering/core'
import { type IntlString, type Resource, mergeIds } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import { workbenchId } from '@hcengineering/workbench'
import workbench from '@hcengineering/workbench-resources/src/plugin'
import type { ActionCategory, ViewActionAvailabilityFunction } from '@hcengineering/view'
export default mergeIds(workbenchId, workbench, {
component: {
@ -30,6 +31,15 @@ export default mergeIds(workbenchId, workbench, {
},
function: {
HasArchiveSpaces: '' as Resource<(spaces: Space[]) => Promise<boolean>>,
IsOwner: '' as Resource<(docs: Doc[]) => Promise<boolean>>
IsOwner: '' as Resource<(docs: Doc[]) => Promise<boolean>>,
CanCloseTab: '' as Resource<ViewActionAvailabilityFunction<Doc>>
},
category: {
Workbench: '' as Ref<ActionCategory>
},
actionImpl: {
PinTab: '' as Resource<(doc?: Doc | Doc[]) => Promise<void>>,
UnpinTab: '' as Resource<(doc?: Doc | Doc[]) => Promise<void>>,
CloseTab: '' as Resource<(doc?: Doc | Doc[]) => Promise<void>>
}
})

View File

@ -292,6 +292,8 @@
min-width: 0;
min-height: 0;
border-radius: var(--small-focus-BorderRadius);
border-top-right-radius: 0;
border-bottom-right-radius:0 ;
&:not(.rowContent) { flex-direction: column; }
.panel-instance & {

View File

@ -150,6 +150,7 @@
.icon {
writing-mode: initial;
text-orientation: initial;
margin: 0;
&.vertical {
transform: rotate(90deg);
text-orientation: upright;
@ -158,6 +159,10 @@
.close-button {
display: flex;
&.horizontal {
margin: 0;
}
&.vertical {
transform: rotate(90deg);
}

View File

@ -208,6 +208,8 @@
z-index: 401;
position: fixed;
background-color: transparent;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@media print {
position: static;

View File

@ -161,7 +161,7 @@
</button>
</div>
{/if}
<div class="flex-row-center" style:-webkit-app-region={'no-drag'}>
<div class="flex-row-center left-items" style:-webkit-app-region={'no-drag'}>
<RootBarExtension position="left" />
</div>
<div
@ -267,4 +267,8 @@
}
}
}
.left-items {
overflow-x: auto;
}
</style>

View File

@ -11,9 +11,11 @@
}
oldLoc = newLocation.path[0]
})
$: sorted = $rootBarExtensions.sort((a, b) => a[1].order - b[1].order)
</script>
{#each $rootBarExtensions as ext (ext[1].id)}
{#each sorted as ext (ext[1].id)}
{#if ext[0] === position}
<div id={ext[1].id} style:margin-right={'1px'}>
<Component

View File

@ -306,6 +306,7 @@ Array<
id: string
component: AnyComponent | AnySvelteComponent
props?: Record<string, any>
order: number
}
]
>
@ -331,14 +332,15 @@ export async function formatDuration (duration: number, language: string): Promi
return text
}
export function pushRootBarComponent (pos: 'left' | 'right', component: AnyComponent): void {
export function pushRootBarComponent (pos: 'left' | 'right', component: AnyComponent, order?: number): void {
rootBarExtensions.update((cur) => {
if (cur.find((p) => p[1].component === component) === undefined) {
cur.push([
pos,
{
id: component,
component
component,
order: order ?? 1000
}
])
}
@ -369,6 +371,7 @@ export function pushRootBarProgressComponent (
{
id,
component: RootStatusComponent,
order: 10,
props: {
label,
onProgress,

View File

@ -0,0 +1,90 @@
<!--
// Copyright © 2024 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 { WorkbenchTab } from '@hcengineering/workbench'
import {
getNotificationsCount,
InboxNotificationsClientImpl,
isActivityNotification,
isMentionNotification,
NotifyMarker
} from '@hcengineering/notification-resources'
import { getClient } from '@hcengineering/presentation'
import { InboxNotification } from '@hcengineering/notification'
import { onDestroy } from 'svelte'
import { concatLink, Doc, Ref } from '@hcengineering/core'
import view, { decodeObjectURI } from '@hcengineering/view'
import { chunterId } from '@hcengineering/chunter'
import { parseLinkId } from '@hcengineering/view-resources'
import { parseLocation } from '@hcengineering/ui'
import chunter from '../plugin'
export let tab: WorkbenchTab
const client = getClient()
const hierarchy = client.getHierarchy()
const notificationClient = InboxNotificationsClientImpl.getClient()
const contextByDocStore = notificationClient.contextByDoc
let objectId: Ref<Doc> | undefined = undefined
let notifications: InboxNotification[] = []
let count = 0
$: context = objectId !== undefined ? $contextByDocStore.get(objectId) : undefined
$: void updateObjectId(tab)
async function updateObjectId (tab: WorkbenchTab): Promise<void> {
const base = `${window.location.protocol}//${window.location.host}`
const url = new URL(concatLink(base, tab.location))
const loc = parseLocation(url)
if (loc.path[2] !== chunterId) {
objectId = undefined
return
}
const client = getClient()
const providers = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const [id, _class] = decodeObjectURI(loc.path[3])
objectId = await parseLinkId(providers, id, _class)
}
const unsubscribe = notificationClient.inboxNotificationsByContext.subscribe((res) => {
if (context === undefined) {
count = 0
return
}
notifications = (res.get(context._id) ?? []).filter((n) => {
if (isActivityNotification(n)) return true
return isMentionNotification(n) && hierarchy.isDerived(n.mentionedInClass, chunter.class.ChatMessage)
})
})
$: void getNotificationsCount(context, notifications).then((res) => {
count = res
})
onDestroy(() => {
unsubscribe()
})
</script>
{#if count > 0}
<NotifyMarker kind="simple" size="xx-small" />
{/if}

View File

@ -52,6 +52,7 @@ import ThreadView from './components/threads/ThreadView.svelte'
import ThreadViewPanel from './components/threads/ThreadViewPanel.svelte'
import ChatWidget from './components/ChatWidget.svelte'
import ChatWidgetTab from './components/ChatWidgetTab.svelte'
import WorkbenchTabExtension from './components/WorkbenchTabExtension.svelte'
import {
chunterSpaceLinkFragmentProvider,
@ -59,6 +60,7 @@ import {
getMessageLink,
getMessageLocation,
getThreadLink,
locationDataResolver,
openChannelInSidebar,
openChannelInSidebarAction,
openThreadInSidebar,
@ -178,7 +180,8 @@ export default async (): Promise<Resources> => ({
ChatMessagePreview,
JoinChannelNotificationPresenter,
ChatWidget,
ChatWidgetTab
ChatWidgetTab,
WorkbenchTabExtension
},
activity: {
ChannelCreatedMessage,
@ -203,7 +206,8 @@ export default async (): Promise<Resources> => ({
CloseChatWidgetTab: closeChatWidgetTab,
OpenChannelInSidebar: openChannelInSidebar,
CanTranslateMessage: canTranslateMessage,
OpenThreadInSidebar: openThreadInSidebar
OpenThreadInSidebar: openThreadInSidebar,
LocationDataResolver: locationDataResolver
},
actionImpl: {
ArchiveChannel,

View File

@ -4,7 +4,8 @@ import {
getCurrentResolvedLocation,
getLocation,
type Location,
navigate
navigate,
languageStore
} from '@hcengineering/ui'
import { type Ref, type Doc, type Class, generateId } from '@hcengineering/core'
import activity, { type ActivityMessage } from '@hcengineering/activity'
@ -16,12 +17,12 @@ import {
type ThreadMessage
} from '@hcengineering/chunter'
import { type DocNotifyContext, notificationId } from '@hcengineering/notification'
import workbench, { type Widget, workbenchId } from '@hcengineering/workbench'
import { classIcon, getObjectLinkId } from '@hcengineering/view-resources'
import workbench, { type Widget, workbenchId, type LocationData } from '@hcengineering/workbench'
import { classIcon, getObjectLinkId, parseLinkId } from '@hcengineering/view-resources'
import { getClient } from '@hcengineering/presentation'
import view, { encodeObjectURI, decodeObjectURI } from '@hcengineering/view'
import { createWidgetTab, isElementFromSidebar, sidebarStore } from '@hcengineering/workbench-resources'
import { type Asset, translate } from '@hcengineering/platform'
import { type Asset, type IntlString, translate } from '@hcengineering/platform'
import contact from '@hcengineering/contact'
import { get } from 'svelte/store'
@ -385,3 +386,73 @@ export function closeChatWidgetTab (tab?: ChatWidgetTab): void {
removeThreadFromLoc(tab.data.thread)
}
}
export async function locationDataResolver (loc: Location): Promise<LocationData> {
const point = loc.path[3]
if (point == null || point === '') {
return { name: await translate(chunter.string.Chat, {}, get(languageStore)) }
}
const specialsData: Record<
string,
{
label: IntlString
icon: Asset
}
> = {
threads: {
label: chunter.string.Threads,
icon: chunter.icon.Chunter
// icon: chunter.icon.Thread
},
saved: {
label: chunter.string.Saved,
icon: chunter.icon.Chunter
// icon: chunter.icon.Bookmarks
},
chunterBrowser: {
label: chunter.string.ChunterBrowser,
icon: chunter.icon.Chunter
// icon: chunter.icon.ChunterBrowser
},
channels: {
label: chunter.string.Channels,
icon: chunter.icon.Chunter
// icon: chunter.icon.Hashtag
}
}
const specialData = specialsData[point]
if (specialData !== undefined) {
return { name: await translate(specialData.label, {}, get(languageStore)), icon: specialData.icon }
}
const client = getClient()
const hierarchy = client.getHierarchy()
const [id, _class] = decodeObjectURI(loc.path[3])
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const _id: Ref<Doc> | undefined = await parseLinkId(linkProviders, id, _class)
const object = await client.findOne(_class, { _id })
if (object === undefined) return { name: await translate(chunter.string.Chat, {}, get(languageStore)) }
const titleIntl = client.getHierarchy().getClass(object._class).label
const iconMixin = hierarchy.classHierarchyMixin(_class, view.mixin.ObjectIcon)
const isDirect = hierarchy.isDerived(_class, chunter.class.DirectMessage)
const isChunterSpace = hierarchy.isDerived(_class, chunter.class.ChunterSpace)
const name = (await getChannelName(_id, _class, object)) ?? (await translate(titleIntl, {}))
return {
name,
icon: chunter.icon.Chunter,
iconComponent: isChunterSpace ? iconMixin?.component : undefined,
iconProps: {
_id: object._id,
value: object,
size: isDirect ? 'tiny' : 'x-small'
}
}
}

View File

@ -59,6 +59,7 @@
"@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"svelte": "^4.2.12"
}
}

View File

@ -144,7 +144,8 @@ import {
getCurrentEmployeePosition,
getPersonTooltip,
grouppingPersonManager,
resolveLocation
resolveLocation,
resolveLocationData
} from './utils'
export * from './utils'
@ -444,7 +445,8 @@ export default async (): Promise<Resources> => ({
PersonFilterFunction: filterPerson
},
resolver: {
Location: resolveLocation
Location: resolveLocation,
LocationData: resolveLocationData
},
aggregation: {
// eslint-disable-next-line @typescript-eslint/unbound-method

View File

@ -50,7 +50,7 @@ import core, {
type WithLookup
} from '@hcengineering/core'
import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification'
import { getEmbeddedLabel, getResource, translate } from '@hcengineering/platform'
import { type IntlString, getEmbeddedLabel, getResource, translate } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { type TemplateDataProvider } from '@hcengineering/templates'
import {
@ -64,6 +64,7 @@ import {
import view, { type Filter, type GrouppingManager } from '@hcengineering/view'
import { accessDeniedStore, FilterQuery } from '@hcengineering/view-resources'
import { derived, get, writable } from 'svelte/store'
import { type LocationData } from '@hcengineering/workbench'
import contact from './plugin'
import { personStore } from '.'
@ -536,3 +537,30 @@ export function groupPersonAccountValuesWithEmpty (
}
return personAccountList.map((it) => it._id)
}
export async function resolveLocationData (loc: Location): Promise<LocationData> {
const special = loc.path[3]
const specialsData: Record<string, IntlString> = {
companies: contact.string.Organizations,
employees: contact.string.Employees,
persons: contact.string.Persons
}
if (special == null) {
return { nameIntl: contact.string.Contacts }
}
const specialLabel = specialsData[special]
if (specialLabel !== undefined) {
return { nameIntl: specialLabel }
}
const client = getClient()
const object = await client.findOne(contact.class.Contact, { _id: special as Ref<Contact> })
if (object === undefined) {
return { nameIntl: specialLabel }
}
return { name: getName(client.getHierarchy(), object) }
}

View File

@ -299,7 +299,9 @@ export const contactPlugin = plugin(contactId, {
SelectUsers: '' as IntlString,
AddGuest: '' as IntlString,
Members: '' as IntlString,
Contacts: '' as IntlString
Contacts: '' as IntlString,
Employees: '' as IntlString,
Persons: '' as IntlString
},
viewlet: {
TableMember: '' as Ref<Viewlet>,

View File

@ -175,7 +175,10 @@ export function getDocumentLinkId (doc: Document): string {
return `${slug}-${doc._id}`
}
export function parseDocumentId (shortLink: string): Ref<Document> | undefined {
export function parseDocumentId (shortLink?: string): Ref<Document> | undefined {
if (shortLink === undefined) {
return undefined
}
const parts = shortLink.split('-')
if (parts.length > 1) {
return parts[parts.length - 1] as Ref<Document>

View File

@ -34,7 +34,7 @@
}
onMount(() => {
pushRootBarComponent('left', love.component.ControlExt)
pushRootBarComponent('left', love.component.ControlExt, 20)
lk.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
lk.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
})

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Widget preferences",
"OpenInSidebar": "Open in sidebar",
"OpenInSidebarNewTab": "Open in sidebar new tab",
"ConfigureWidgets": "Configure widgets"
"ConfigureWidgets": "Configure widgets",
"Tab": "Tab"
}
}

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Preferencias del widget",
"OpenInSidebar": "Abrir en la barra lateral",
"OpenInSidebarNewTab": "Abrir en una nueva pestaña de la barra lateral",
"ConfigureWidgets": "Configurar widgets"
"ConfigureWidgets": "Configurar widgets",
"Tab": "Pestaña"
}
}

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Préférences du widget",
"OpenInSidebar": "Ouvrir dans la barre latérale",
"OpenInSidebarNewTab": "Ouvrir dans un nouvel onglet de la barre latérale",
"ConfigureWidgets": "Configurer les widgets"
"ConfigureWidgets": "Configurer les widgets",
"Tab": "Onglet"
}
}

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Preferências do widget",
"OpenInSidebar": "Abrir na barra lateral",
"OpenInSidebarNewTab": "Abrir em uma nova aba da barra lateral",
"ConfigureWidgets": "Configurar widgets"
"ConfigureWidgets": "Configurar widgets",
"Tab": "Aba"
}
}

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "Настройки виджета",
"OpenInSidebar": "Открыть в боковой панели",
"OpenInSidebarNewTab": "Открыть в новой вкладке боковой панели",
"ConfigureWidgets": "Настроить виджеты"
"ConfigureWidgets": "Настроить виджеты",
"Tab": "Вкладка"
}
}

View File

@ -34,6 +34,7 @@
"WidgetPreferences": "小部件首选项",
"OpenInSidebar": "在侧边栏中打开",
"OpenInSidebarNewTab": "在侧边栏新标签页中打开",
"ConfigureWidgets": "配置小部件"
"ConfigureWidgets": "配置小部件",
"Tab": "选项卡"
}
}

View File

@ -16,11 +16,20 @@
import { Analytics } from '@hcengineering/analytics'
import contact, { PersonAccount } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources'
import core, { AccountRole, Class, Doc, Ref, Space, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
import core, {
AccountRole,
Class,
Doc,
getCurrentAccount,
hasAccountRole,
Ref,
SortingOrder,
Space
} from '@hcengineering/core'
import login from '@hcengineering/login'
import notification, { DocNotifyContext, InboxNotification, notificationId } from '@hcengineering/notification'
import { BrowserNotificatator, InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { IntlString, broadcastEvent, getMetadata, getResource } from '@hcengineering/platform'
import { broadcastEvent, getMetadata, getResource, IntlString } from '@hcengineering/platform'
import {
ActionContext,
ComponentExtensions,
@ -30,55 +39,62 @@
reduceCalls
} from '@hcengineering/presentation'
import setting from '@hcengineering/setting'
import support, { SupportStatus, supportLink } from '@hcengineering/support'
import support, { supportLink, SupportStatus } from '@hcengineering/support'
import {
AnyComponent,
areLocationsEqual,
Button,
closePanel,
closePopup,
closeTooltip,
CompAndProps,
Component,
defineSeparators,
deviceOptionsStore as deviceInfo,
Dock,
getCurrentLocation,
getLocation,
IconSettings,
Label,
Location,
location,
locationStorageKeyId,
locationToUrl,
mainSeparators,
navigate,
openPanel,
PanelInstance,
Popup,
PopupAlignment,
PopupPosAlignment,
PopupResult,
ResolvedLocation,
Separator,
TooltipInstance,
areLocationsEqual,
closePanel,
closePopup,
closeTooltip,
defineSeparators,
deviceOptionsStore as deviceInfo,
getCurrentLocation,
getLocation,
location,
locationStorageKeyId,
navigate,
openPanel,
popupstore,
pushRootBarComponent,
ResolvedLocation,
resolvedLocationStore,
Separator,
setResolvedLocation,
showPopup,
workbenchSeparators,
mainSeparators
TooltipInstance,
workbenchSeparators
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import {
accessDeniedStore,
ActionHandler,
ListSelectionProvider,
NavLink,
accessDeniedStore,
migrateViewOpttions,
NavLink,
parseLinkId,
updateFocus
} from '@hcengineering/view-resources'
import type { Application, NavigatorModel, SpecialNavModel, ViewConfiguration } from '@hcengineering/workbench'
import type {
Application,
NavigatorModel,
SpecialNavModel,
ViewConfiguration,
WorkbenchTab
} from '@hcengineering/workbench'
import { getContext, onDestroy, onMount, tick } from 'svelte'
import { subscribeMobile } from '../mobile'
import workbench from '../plugin'
@ -96,6 +112,7 @@
import TopMenu from './icons/TopMenu.svelte'
import WidgetsBar from './sidebar/Sidebar.svelte'
import { sidebarStore, SidebarVariant, syncSidebarState } from '../sidebar'
import { getTabLocation, selectTab, syncWorkbenchTab, tabIdStore, tabsStore } from '../workbench'
let contentPanel: HTMLElement
@ -142,13 +159,74 @@
}
}
let tabs: WorkbenchTab[] = []
let areTabsLoaded = false
let prevTab: Ref<WorkbenchTab> | undefined
const query = createQuery()
$: query.query(
workbench.class.WorkbenchTab,
{},
(res) => {
tabs = res
tabsStore.set(tabs)
if (!areTabsLoaded) {
void initCurrentTab(tabs)
areTabsLoaded = true
}
},
{
sort: {
isPinned: SortingOrder.Descending,
createdOn: SortingOrder.Ascending
}
}
)
async function initCurrentTab (tabs: WorkbenchTab[]): Promise<void> {
if (tabs.length === 0) {
const loc = getCurrentLocation()
const _id = await client.createDoc(workbench.class.WorkbenchTab, core.space.Workspace, {
attachedTo: account._id,
location: locationToUrl(loc),
isPinned: false
})
prevTab = _id
selectTab(_id)
} else {
const tab = tabs.find((t) => t._id === $tabIdStore)
const loc = getCurrentLocation()
const tabLoc = tab ? getTabLocation(tab) : undefined
const isLocEqual = tabLoc ? areLocationsEqual(loc, tabLoc) : false
if (!isLocEqual) {
const url = locationToUrl(loc)
const tabByUrl = tabs.find((t) => t.location === url)
if (tabByUrl !== undefined) {
prevTab = tabByUrl._id
selectTab(tabByUrl._id)
} else {
const _id = await client.createDoc(workbench.class.WorkbenchTab, core.space.Workspace, {
attachedTo: account._id,
location: url,
isPinned: false
})
prevTab = _id
selectTab(_id)
}
}
}
}
onMount(() => {
pushRootBarComponent('right', view.component.SearchSelector)
pushRootBarComponent('left', workbench.component.WorkbenchTabs, 30)
void getResource(login.function.GetWorkspaces).then(async (getWorkspaceFn) => {
$workspacesStore = await getWorkspaceFn()
await updateWindowTitle(getLocation())
})
syncSidebarState()
syncWorkbenchTab()
})
const account = getCurrentAccount() as PersonAccount
@ -296,7 +374,13 @@
async function syncLoc (loc: Location): Promise<void> {
accessDeniedStore.set(false)
const originalLoc = JSON.stringify(loc)
if ($tabIdStore !== prevTab) {
if (prevTab) {
clear(1)
clear(2)
}
prevTab = $tabIdStore
}
if (loc.path.length > 3 && getSpecialComponent(loc.path[3]) === undefined) {
// resolve short links
const resolvedLoc = await resolveShortLink(loc)

View File

@ -0,0 +1,135 @@
<!--
// Copyright © 2024 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 { AnySvelteComponent, closePanel, getCurrentLocation, Location, ModernTab, navigate } from '@hcengineering/ui'
import { ComponentExtensions, getClient } from '@hcengineering/presentation'
import { Asset, getResource, IntlString } from '@hcengineering/platform'
import { type Application, WorkbenchTab } from '@hcengineering/workbench'
import { Class, Doc, Ref } from '@hcengineering/core'
import view from '@hcengineering/view'
import { parseLinkId, showMenu } from '@hcengineering/view-resources'
import { Analytics } from '@hcengineering/analytics'
import { closeTab, getTabLocation, selectTab, tabIdStore, tabsStore } from '../workbench'
import workbench from '../plugin'
export let tab: WorkbenchTab
const client = getClient()
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
let name: string | undefined = undefined
let label: IntlString | undefined = undefined
let icon: Asset | AnySvelteComponent | undefined
let iconProps: Record<string, any> | undefined
async function getResolvedLocation (loc: Location, app?: Application): Promise<Location> {
if (app === undefined) return loc
if (app.locationResolver) {
const resolver = await getResource(app.locationResolver)
return (await resolver(loc))?.loc ?? loc
}
return loc
}
async function getTabName (loc: Location): Promise<string | undefined> {
if (loc.fragment == null) return
const hierarchy = client.getHierarchy()
const [, id, _class] = decodeURIComponent(loc.fragment).split('|')
if (_class == null) return
const mixin = hierarchy.classHierarchyMixin(_class as Ref<Class<Doc>>, view.mixin.ObjectTitle)
if (mixin === undefined) return
const titleProvider = await getResource(mixin.titleProvider)
try {
const _id = await parseLinkId(linkProviders, id, _class as Ref<Class<Doc>>)
return await titleProvider(client, _id)
} catch (err: any) {
Analytics.handleError(err)
console.error(err)
}
}
async function updateTabData (tab: WorkbenchTab): Promise<void> {
const tabLoc = getTabLocation(tab)
const alias = tabLoc.path[2]
const application = client.getModel().findAllSync<Application>(workbench.class.Application, { alias })[0]
if (application?.locationDataResolver) {
const resolver = await getResource(application.locationDataResolver)
const data = await resolver(tabLoc)
name = data.name
label = data.nameIntl ?? application.label ?? workbench.string.Tab
if (data.iconComponent) {
icon = await getResource(data.iconComponent)
} else {
icon = data.icon ?? application?.icon
}
iconProps = data.iconProps
} else {
const special = tabLoc.path[3]
const specialLabel = application?.navigatorModel?.specials?.find((s) => s.id === special)?.label
const resolvedLoc = await getResolvedLocation(tabLoc, application)
name = await getTabName(resolvedLoc)
label = specialLabel ?? application?.label ?? workbench.string.Tab
icon = application?.icon
iconProps = undefined
}
}
$: void updateTabData(tab)
function handleClickTab (): void {
selectTab(tab._id)
const tabLoc = getTabLocation(tab)
const loc = getCurrentLocation()
const currentApp = loc.path[2]
const newApp = tabLoc.path[2]
if (newApp && newApp !== currentApp) {
closePanel(false)
}
navigate(tabLoc)
}
function handleCloseTab (): void {
void closeTab(tab)
}
function handleMenu (event: MouseEvent): void {
event.preventDefault()
event.stopPropagation()
showMenu(event, { object: tab, baseMenuClass: workbench.class.WorkbenchTab })
}
</script>
<ModernTab
labelIntl={label}
label={name}
{icon}
{iconProps}
maxSize="12rem"
highlighted={$tabIdStore === tab._id}
canClose={$tabsStore.length > 1 && !tab.isPinned}
on:click={handleClickTab}
on:close={handleCloseTab}
on:contextmenu={handleMenu}
>
<svelte:fragment slot="prefix">
<ComponentExtensions extension={workbench.extensions.WorkbenchTabExtensions} props={{ tab }} />
</svelte:fragment>
</ModernTab>

View File

@ -0,0 +1,38 @@
<!--
// Copyright © 2024 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 { createTab, tabsStore } from '../workbench'
import WorkbenchTabPresenter from './WorkbenchTabPresenter.svelte'
import { IconAdd, ButtonIcon } from '@hcengineering/ui'
</script>
<div class="root flex-gap-1">
{#each $tabsStore as tab}
<WorkbenchTabPresenter {tab} />
{/each}
<div class="ml-1-5 plus-button mr-1">
<ButtonIcon icon={IconAdd} size="min" kind="tertiary" on:click={createTab} />
</div>
</div>
<style>
.root {
display: flex;
align-items: center;
}
.plus-button {
height: 0.875rem;
}
</style>

View File

@ -23,7 +23,9 @@ import WorkbenchApp from './components/WorkbenchApp.svelte'
import { doNavigate } from './utils'
import Workbench from './components/Workbench.svelte'
import ServerManager from './components/ServerManager.svelte'
import WorkbenchTabs from './components/WorkbenchTabs.svelte'
import { isAdminUser } from '@hcengineering/presentation'
import { canCloseTab, closeTab, pinTab, unpinTab } from './workbench'
async function hasArchiveSpaces (spaces: Space[]): Promise<boolean> {
return spaces.find((sp) => sp.archived) !== undefined
@ -46,13 +48,18 @@ export default async (): Promise<Resources> => ({
SpacePanel,
SpecialView,
Workbench,
ServerManager
ServerManager,
WorkbenchTabs
},
function: {
HasArchiveSpaces: hasArchiveSpaces,
IsOwner: async (docs: Space[]) => getCurrentAccount().role === AccountRole.Owner || isAdminUser()
IsOwner: async (docs: Space[]) => getCurrentAccount().role === AccountRole.Owner || isAdminUser(),
CanCloseTab: canCloseTab
},
actionImpl: {
Navigate: doNavigate
Navigate: doNavigate,
PinTab: pinTab,
UnpinTab: unpinTab,
CloseTab: closeTab
}
})

View File

@ -47,13 +47,15 @@ export default mergeIds(workbenchId, workbench, {
WorkspaceCreating: '' as IntlString,
AccessDenied: '' as IntlString,
Widget: '' as IntlString,
WidgetPreference: '' as IntlString
WidgetPreference: '' as IntlString,
Tab: '' as IntlString
},
metadata: {
MobileAllowed: '' as Metadata<boolean>
},
component: {
SpacePanel: '' as AnyComponent,
Workbench: '' as AnyComponent
Workbench: '' as AnyComponent,
WorkbenchTabs: '' as AnyComponent
}
})

View File

@ -0,0 +1,138 @@
//
// Copyright © 2024 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 { derived, get, writable } from 'svelte/store'
import core, { concatLink, getCurrentAccount, type Ref } from '@hcengineering/core'
import workbench, { type WorkbenchTab } from '@hcengineering/workbench'
import {
location as locationStore,
locationToUrl,
parseLocation,
type Location,
navigate,
getCurrentLocation
} from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import { workspaceStore } from './utils'
export const tabIdStore = writable<Ref<WorkbenchTab> | undefined>()
export const tabsStore = writable<WorkbenchTab[]>([])
export const currentTabStore = derived([tabIdStore, tabsStore], ([tabId, tabs]) => {
return tabs.find((tab) => tab._id === tabId)
})
workspaceStore.subscribe((workspace) => {
tabIdStore.set(getTabFromLocalStorage(workspace ?? ''))
})
locationStore.subscribe((loc) => {
const workspace = get(workspaceStore)
if (workspace == null || workspace === '') return
const tab = get(currentTabStore)
if (tab == null) return
const tabId = tab._id
if (tabId == null || tab._id !== tabId) return
void getClient().update(tab, { location: locationToUrl(loc) })
})
tabIdStore.subscribe(saveTabToLocalStorage)
export function syncWorkbenchTab (): void {
const workspace = get(workspaceStore)
tabIdStore.set(getTabFromLocalStorage(workspace ?? ''))
}
function getTabIdLocalStorageKey (workspace: string): string | undefined {
const me = getCurrentAccount()
if (me == null || workspace === '') return undefined
return `workbench.${workspace}.${me.person}.tab`
}
function getTabFromLocalStorage (workspace: string): Ref<WorkbenchTab> | undefined {
const localStorageKey = getTabIdLocalStorageKey(workspace)
if (localStorageKey === undefined) return undefined
const tab = window.localStorage.getItem(localStorageKey)
if (tab == null || tab === '') return undefined
return tab as Ref<WorkbenchTab>
}
function saveTabToLocalStorage (_id: Ref<WorkbenchTab> | undefined): void {
const workspace = get(workspaceStore)
if (workspace == null || workspace === '') return
const localStorageKey = getTabIdLocalStorageKey(workspace)
if (localStorageKey === undefined) return
window.localStorage.setItem(localStorageKey, _id ?? '')
}
export function selectTab (_id: Ref<WorkbenchTab>): void {
tabIdStore.set(_id)
}
export function getTabLocation (tab: WorkbenchTab): Location {
const base = `${window.location.protocol}//${window.location.host}`
const url = new URL(concatLink(base, tab.location))
return parseLocation(url)
}
export async function closeTab (tab: WorkbenchTab): Promise<void> {
const tabs = get(tabsStore)
const index = tabs.findIndex((t) => t._id === tab._id)
if (index === -1) return
tabsStore.update((tabs) => tabs.filter((t) => t._id !== tab._id))
if (get(tabIdStore) === tab._id) {
const newTab = tabs[index - 1] ?? tabs[index + 1]
tabIdStore.set(newTab?._id)
if (newTab !== undefined) {
navigate(getTabLocation(newTab))
}
}
const client = getClient()
await client.remove(tab)
}
export async function createTab (): Promise<void> {
const loc = getCurrentLocation()
const client = getClient()
const me = getCurrentAccount()
const tab = await client.createDoc(workbench.class.WorkbenchTab, core.space.Workspace, {
attachedTo: me._id,
location: locationToUrl(loc),
isPinned: false
})
selectTab(tab)
}
export function canCloseTab (tab: WorkbenchTab): boolean {
const tabs = get(tabsStore)
return tabs.length > 1 && tabs.some((t) => t._id === tab._id)
}
export async function pinTab (tab: WorkbenchTab): Promise<void> {
const client = getClient()
await client.update(tab, { isPinned: true })
}
export async function unpinTab (tab: WorkbenchTab): Promise<void> {
const client = getClient()
await client.update(tab, { isPinned: false })
}

View File

@ -30,6 +30,15 @@ import { ViewAction } from '@hcengineering/view'
/**
* @public
*/
export interface LocationData {
name?: string
nameIntl?: IntlString
icon?: Asset
iconComponent?: AnyComponent
iconProps?: Record<string, any>
}
export interface Application extends Doc {
label: IntlString
alias: string
@ -42,6 +51,7 @@ export interface Application extends Doc {
aside?: AnyComponent
locationResolver?: Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>
locationDataResolver?: Resource<(loc: Location) => Promise<LocationData>>
// Component will be displayed in case navigator model is not defined, or nothing is selected in navigator model
component?: AnyComponent
@ -102,6 +112,11 @@ export interface TxSidebarEvent<T extends Record<string, any> = Record<string, a
params: T
}
export interface WorkbenchTab extends Preference {
location: string
isPinned: boolean
}
/**
* @public
*/
@ -201,7 +216,8 @@ export default plugin(workbenchId, {
HiddenApplication: '' as Ref<Class<HiddenApplication>>,
Widget: '' as Ref<Class<Widget>>,
WidgetPreference: '' as Ref<Class<WidgetPreference>>,
TxSidebarEvent: '' as Ref<Class<TxSidebarEvent<Record<string, any>>>>
TxSidebarEvent: '' as Ref<Class<TxSidebarEvent<Record<string, any>>>>,
WorkbenchTab: '' as Ref<Class<WorkbenchTab>>
},
mixin: {
SpaceView: '' as Ref<Mixin<SpaceView>>
@ -238,7 +254,8 @@ export default plugin(workbenchId, {
NavigationExpandedDefault: '' as Metadata<boolean>
},
extensions: {
WorkbenchExtensions: '' as ComponentExtensionId
WorkbenchExtensions: '' as ComponentExtensionId,
WorkbenchTabExtensions: '' as ComponentExtensionId
},
actionImpl: {
Navigate: '' as ViewAction<{