From b5fed4879f65daa7bac0eefeb93b9bceb2aee4dc Mon Sep 17 00:00:00 2001 From: Kristina Date: Wed, 2 Oct 2024 22:49:49 +0400 Subject: [PATCH] Add workbench tabs (#6788) Signed-off-by: Kristina Fefelova --- models/chunter/src/index.ts | 6 + models/chunter/src/plugin.ts | 8 +- models/contact/package.json | 3 +- models/contact/src/index.ts | 1 + models/contact/src/plugin.ts | 8 +- models/workbench/package.json | 11 +- models/workbench/src/index.ts | 60 +++++++- models/workbench/src/plugin.ts | 14 +- packages/theme/styles/panel.scss | 2 + packages/ui/src/components/ModernTab.svelte | 5 + .../ui/src/components/PanelInstance.svelte | 2 + .../ui/src/components/internal/Root.svelte | 6 +- .../internal/RootBarExtension.svelte | 4 +- packages/ui/src/utils.ts | 7 +- .../components/WorkbenchTabExtension.svelte | 90 ++++++++++++ plugins/chunter-resources/src/index.ts | 8 +- plugins/chunter-resources/src/navigation.ts | 79 +++++++++- plugins/contact-resources/package.json | 1 + plugins/contact-resources/src/index.ts | 6 +- plugins/contact-resources/src/utils.ts | 30 +++- plugins/contact/src/index.ts | 4 +- plugins/document-resources/src/utils.ts | 5 +- .../src/components/WorkbenchExtension.svelte | 2 +- plugins/workbench-assets/lang/en.json | 3 +- plugins/workbench-assets/lang/es.json | 3 +- plugins/workbench-assets/lang/fr.json | 3 +- plugins/workbench-assets/lang/pt.json | 3 +- plugins/workbench-assets/lang/ru.json | 3 +- plugins/workbench-assets/lang/zh.json | 3 +- .../src/components/Workbench.svelte | 132 ++++++++++++++--- .../components/WorkbenchTabPresenter.svelte | 135 +++++++++++++++++ .../src/components/WorkbenchTabs.svelte | 38 +++++ plugins/workbench-resources/src/index.ts | 13 +- plugins/workbench-resources/src/plugin.ts | 6 +- plugins/workbench-resources/src/workbench.ts | 138 ++++++++++++++++++ plugins/workbench/src/index.ts | 21 ++- 36 files changed, 794 insertions(+), 69 deletions(-) create mode 100644 plugins/chunter-resources/src/components/WorkbenchTabExtension.svelte create mode 100644 plugins/workbench-resources/src/components/WorkbenchTabPresenter.svelte create mode 100644 plugins/workbench-resources/src/components/WorkbenchTabs.svelte create mode 100644 plugins/workbench-resources/src/workbench.ts diff --git a/models/chunter/src/index.ts b/models/chunter/src/index.ts index 45f4519165..0c25e70dda 100644 --- a/models/chunter/src/index.ts +++ b/models/chunter/src/index.ts @@ -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) => { diff --git a/models/chunter/src/plugin.ts b/models/chunter/src/plugin.ts index 8116ad3290..3ac6ba5dcc 100644 --- a/models/chunter/src/plugin.ts +++ b/models/chunter/src/plugin.ts @@ -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, @@ -108,7 +109,8 @@ export default mergeIds(chunterId, chunter, { ReplyToThread: '' as Resource<(doc: ActivityMessage, event: MouseEvent) => Promise>, CanReplyToThread: '' as Resource<(doc?: Doc | Doc[]) => Promise>, GetMessageLink: '' as Resource<(doc: Doc, props: Record) => Promise>, - CloseChatWidgetTab: '' as Resource<(tab: WidgetTab) => Promise> + CloseChatWidgetTab: '' as Resource<(tab: WidgetTab) => Promise>, + LocationDataResolver: '' as Resource<(loc: Location) => Promise> }, filter: { ChatMessagesFilter: '' as Resource<(message: ActivityMessage, _class?: Ref) => boolean> diff --git a/models/contact/package.json b/models/contact/package.json index 111ae9f922..985b92236b 100644 --- a/models/contact/package.json +++ b/models/contact/package.json @@ -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" } } diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index b85a8cd7b0..1109e5549e 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -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: [ diff --git a/models/contact/src/plugin.ts b/models/contact/src/plugin.ts index ff404adb50..6e81f1de7d 100644 --- a/models/contact/src/plugin.ts +++ b/models/contact/src/plugin.ts @@ -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) => Promise>, SetPersonStore: '' as Resource<(manager: DocManager) => void>, PersonFilterFunction: '' as Resource<(doc: Doc, target: Doc) => boolean> + }, + resolver: { + LocationData: '' as Resource<(loc: Location) => Promise> } }) diff --git a/models/workbench/package.json b/models/workbench/package.json index e45169682d..1270cc94d9 100644 --- a/models/workbench/package.json +++ b/models/workbench/package.json @@ -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" } } diff --git a/models/workbench/src/index.ts b/models/workbench/src/index.ts index 493d425d2a..d7810e554a 100644 --- a/models/workbench/src/index.ts +++ b/models/workbench/src/index.ts @@ -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 } +@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 diff --git a/models/workbench/src/plugin.ts b/models/workbench/src/plugin.ts index 2767f62b86..35a63ec27e 100644 --- a/models/workbench/src/plugin.ts +++ b/models/workbench/src/plugin.ts @@ -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>, - IsOwner: '' as Resource<(docs: Doc[]) => Promise> + IsOwner: '' as Resource<(docs: Doc[]) => Promise>, + CanCloseTab: '' as Resource> + }, + category: { + Workbench: '' as Ref + }, + actionImpl: { + PinTab: '' as Resource<(doc?: Doc | Doc[]) => Promise>, + UnpinTab: '' as Resource<(doc?: Doc | Doc[]) => Promise>, + CloseTab: '' as Resource<(doc?: Doc | Doc[]) => Promise> } }) diff --git a/packages/theme/styles/panel.scss b/packages/theme/styles/panel.scss index c9280ea933..6740fc65b7 100644 --- a/packages/theme/styles/panel.scss +++ b/packages/theme/styles/panel.scss @@ -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 & { diff --git a/packages/ui/src/components/ModernTab.svelte b/packages/ui/src/components/ModernTab.svelte index c9fb9e2a7f..9ad71d474d 100644 --- a/packages/ui/src/components/ModernTab.svelte +++ b/packages/ui/src/components/ModernTab.svelte @@ -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); } diff --git a/packages/ui/src/components/PanelInstance.svelte b/packages/ui/src/components/PanelInstance.svelte index fd449f31a3..887a3dea09 100644 --- a/packages/ui/src/components/PanelInstance.svelte +++ b/packages/ui/src/components/PanelInstance.svelte @@ -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; diff --git a/packages/ui/src/components/internal/Root.svelte b/packages/ui/src/components/internal/Root.svelte index d0527568c9..b1fccfe0f5 100644 --- a/packages/ui/src/components/internal/Root.svelte +++ b/packages/ui/src/components/internal/Root.svelte @@ -161,7 +161,7 @@ {/if} -
+
diff --git a/packages/ui/src/components/internal/RootBarExtension.svelte b/packages/ui/src/components/internal/RootBarExtension.svelte index 01a8b7d6ac..5d33a0863e 100644 --- a/packages/ui/src/components/internal/RootBarExtension.svelte +++ b/packages/ui/src/components/internal/RootBarExtension.svelte @@ -11,9 +11,11 @@ } oldLoc = newLocation.path[0] }) + + $: sorted = $rootBarExtensions.sort((a, b) => a[1].order - b[1].order) -{#each $rootBarExtensions as ext (ext[1].id)} +{#each sorted as ext (ext[1].id)} {#if ext[0] === position}
+ 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, diff --git a/plugins/chunter-resources/src/components/WorkbenchTabExtension.svelte b/plugins/chunter-resources/src/components/WorkbenchTabExtension.svelte new file mode 100644 index 0000000000..4a7aec6452 --- /dev/null +++ b/plugins/chunter-resources/src/components/WorkbenchTabExtension.svelte @@ -0,0 +1,90 @@ + + + +{#if count > 0} + +{/if} diff --git a/plugins/chunter-resources/src/index.ts b/plugins/chunter-resources/src/index.ts index 864a1e0ef1..242d120c81 100644 --- a/plugins/chunter-resources/src/index.ts +++ b/plugins/chunter-resources/src/index.ts @@ -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 => ({ ChatMessagePreview, JoinChannelNotificationPresenter, ChatWidget, - ChatWidgetTab + ChatWidgetTab, + WorkbenchTabExtension }, activity: { ChannelCreatedMessage, @@ -203,7 +206,8 @@ export default async (): Promise => ({ CloseChatWidgetTab: closeChatWidgetTab, OpenChannelInSidebar: openChannelInSidebar, CanTranslateMessage: canTranslateMessage, - OpenThreadInSidebar: openThreadInSidebar + OpenThreadInSidebar: openThreadInSidebar, + LocationDataResolver: locationDataResolver }, actionImpl: { ArchiveChannel, diff --git a/plugins/chunter-resources/src/navigation.ts b/plugins/chunter-resources/src/navigation.ts index 48111482d4..b4971f101f 100644 --- a/plugins/chunter-resources/src/navigation.ts +++ b/plugins/chunter-resources/src/navigation.ts @@ -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 { + 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 | 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' + } + } +} diff --git a/plugins/contact-resources/package.json b/plugins/contact-resources/package.json index 23b73e1ff9..8ee962a904 100644 --- a/plugins/contact-resources/package.json +++ b/plugins/contact-resources/package.json @@ -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" } } diff --git a/plugins/contact-resources/src/index.ts b/plugins/contact-resources/src/index.ts index 638b8b272b..fd40412ebe 100644 --- a/plugins/contact-resources/src/index.ts +++ b/plugins/contact-resources/src/index.ts @@ -144,7 +144,8 @@ import { getCurrentEmployeePosition, getPersonTooltip, grouppingPersonManager, - resolveLocation + resolveLocation, + resolveLocationData } from './utils' export * from './utils' @@ -444,7 +445,8 @@ export default async (): Promise => ({ PersonFilterFunction: filterPerson }, resolver: { - Location: resolveLocation + Location: resolveLocation, + LocationData: resolveLocationData }, aggregation: { // eslint-disable-next-line @typescript-eslint/unbound-method diff --git a/plugins/contact-resources/src/utils.ts b/plugins/contact-resources/src/utils.ts index ad63124e20..8116855147 100644 --- a/plugins/contact-resources/src/utils.ts +++ b/plugins/contact-resources/src/utils.ts @@ -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 { + const special = loc.path[3] + const specialsData: Record = { + 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 }) + + if (object === undefined) { + return { nameIntl: specialLabel } + } + + return { name: getName(client.getHierarchy(), object) } +} diff --git a/plugins/contact/src/index.ts b/plugins/contact/src/index.ts index 783109f2f1..d3aa52226a 100644 --- a/plugins/contact/src/index.ts +++ b/plugins/contact/src/index.ts @@ -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, diff --git a/plugins/document-resources/src/utils.ts b/plugins/document-resources/src/utils.ts index 74349bcb7f..4cd548594b 100644 --- a/plugins/document-resources/src/utils.ts +++ b/plugins/document-resources/src/utils.ts @@ -175,7 +175,10 @@ export function getDocumentLinkId (doc: Document): string { return `${slug}-${doc._id}` } -export function parseDocumentId (shortLink: string): Ref | undefined { +export function parseDocumentId (shortLink?: string): Ref | undefined { + if (shortLink === undefined) { + return undefined + } const parts = shortLink.split('-') if (parts.length > 1) { return parts[parts.length - 1] as Ref diff --git a/plugins/love-resources/src/components/WorkbenchExtension.svelte b/plugins/love-resources/src/components/WorkbenchExtension.svelte index 237e233a64..8d84c26bd3 100644 --- a/plugins/love-resources/src/components/WorkbenchExtension.svelte +++ b/plugins/love-resources/src/components/WorkbenchExtension.svelte @@ -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) }) diff --git a/plugins/workbench-assets/lang/en.json b/plugins/workbench-assets/lang/en.json index de66e916fe..4064971be0 100644 --- a/plugins/workbench-assets/lang/en.json +++ b/plugins/workbench-assets/lang/en.json @@ -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" } } diff --git a/plugins/workbench-assets/lang/es.json b/plugins/workbench-assets/lang/es.json index 9ff222a1f4..0bd6e7f4ef 100644 --- a/plugins/workbench-assets/lang/es.json +++ b/plugins/workbench-assets/lang/es.json @@ -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" } } \ No newline at end of file diff --git a/plugins/workbench-assets/lang/fr.json b/plugins/workbench-assets/lang/fr.json index 0ec18659f6..f2b727f07a 100644 --- a/plugins/workbench-assets/lang/fr.json +++ b/plugins/workbench-assets/lang/fr.json @@ -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" } } \ No newline at end of file diff --git a/plugins/workbench-assets/lang/pt.json b/plugins/workbench-assets/lang/pt.json index 8377c9e633..a7df3d0e14 100644 --- a/plugins/workbench-assets/lang/pt.json +++ b/plugins/workbench-assets/lang/pt.json @@ -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" } } \ No newline at end of file diff --git a/plugins/workbench-assets/lang/ru.json b/plugins/workbench-assets/lang/ru.json index 52df6c1b27..f2da9f6c74 100644 --- a/plugins/workbench-assets/lang/ru.json +++ b/plugins/workbench-assets/lang/ru.json @@ -34,6 +34,7 @@ "WidgetPreferences": "Настройки виджета", "OpenInSidebar": "Открыть в боковой панели", "OpenInSidebarNewTab": "Открыть в новой вкладке боковой панели", - "ConfigureWidgets": "Настроить виджеты" + "ConfigureWidgets": "Настроить виджеты", + "Tab": "Вкладка" } } diff --git a/plugins/workbench-assets/lang/zh.json b/plugins/workbench-assets/lang/zh.json index 25ca11aac9..416be8dd78 100644 --- a/plugins/workbench-assets/lang/zh.json +++ b/plugins/workbench-assets/lang/zh.json @@ -34,6 +34,7 @@ "WidgetPreferences": "小部件首选项", "OpenInSidebar": "在侧边栏中打开", "OpenInSidebarNewTab": "在侧边栏新标签页中打开", - "ConfigureWidgets": "配置小部件" + "ConfigureWidgets": "配置小部件", + "Tab": "选项卡" } } diff --git a/plugins/workbench-resources/src/components/Workbench.svelte b/plugins/workbench-resources/src/components/Workbench.svelte index cde59e8963..5a1302a608 100644 --- a/plugins/workbench-resources/src/components/Workbench.svelte +++ b/plugins/workbench-resources/src/components/Workbench.svelte @@ -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 | 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 { + 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 { 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) diff --git a/plugins/workbench-resources/src/components/WorkbenchTabPresenter.svelte b/plugins/workbench-resources/src/components/WorkbenchTabPresenter.svelte new file mode 100644 index 0000000000..12f631dd73 --- /dev/null +++ b/plugins/workbench-resources/src/components/WorkbenchTabPresenter.svelte @@ -0,0 +1,135 @@ + + + + 1 && !tab.isPinned} + on:click={handleClickTab} + on:close={handleCloseTab} + on:contextmenu={handleMenu} +> + + + + diff --git a/plugins/workbench-resources/src/components/WorkbenchTabs.svelte b/plugins/workbench-resources/src/components/WorkbenchTabs.svelte new file mode 100644 index 0000000000..8b4dff11fb --- /dev/null +++ b/plugins/workbench-resources/src/components/WorkbenchTabs.svelte @@ -0,0 +1,38 @@ + + + +
+ {#each $tabsStore as tab} + + {/each} +
+ +
+
+ + diff --git a/plugins/workbench-resources/src/index.ts b/plugins/workbench-resources/src/index.ts index 7d5abf79a8..049bd4b387 100644 --- a/plugins/workbench-resources/src/index.ts +++ b/plugins/workbench-resources/src/index.ts @@ -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 { return spaces.find((sp) => sp.archived) !== undefined @@ -46,13 +48,18 @@ export default async (): Promise => ({ 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 } }) diff --git a/plugins/workbench-resources/src/plugin.ts b/plugins/workbench-resources/src/plugin.ts index 8f540733d7..6e7593a338 100644 --- a/plugins/workbench-resources/src/plugin.ts +++ b/plugins/workbench-resources/src/plugin.ts @@ -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 }, component: { SpacePanel: '' as AnyComponent, - Workbench: '' as AnyComponent + Workbench: '' as AnyComponent, + WorkbenchTabs: '' as AnyComponent } }) diff --git a/plugins/workbench-resources/src/workbench.ts b/plugins/workbench-resources/src/workbench.ts new file mode 100644 index 0000000000..5ebd726ca5 --- /dev/null +++ b/plugins/workbench-resources/src/workbench.ts @@ -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 | undefined>() +export const tabsStore = writable([]) +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 | 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 +} + +function saveTabToLocalStorage (_id: Ref | 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): 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 { + 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 { + 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 { + const client = getClient() + await client.update(tab, { isPinned: true }) +} + +export async function unpinTab (tab: WorkbenchTab): Promise { + const client = getClient() + await client.update(tab, { isPinned: false }) +} diff --git a/plugins/workbench/src/index.ts b/plugins/workbench/src/index.ts index 3385c54485..ada81ea2d2 100644 --- a/plugins/workbench/src/index.ts +++ b/plugins/workbench/src/index.ts @@ -30,6 +30,15 @@ import { ViewAction } from '@hcengineering/view' /** * @public */ + +export interface LocationData { + name?: string + nameIntl?: IntlString + icon?: Asset + iconComponent?: AnyComponent + iconProps?: Record +} + 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> + locationDataResolver?: Resource<(loc: Location) => Promise> // 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 = Record>, Widget: '' as Ref>, WidgetPreference: '' as Ref>, - TxSidebarEvent: '' as Ref>>> + TxSidebarEvent: '' as Ref>>>, + WorkbenchTab: '' as Ref> }, mixin: { SpaceView: '' as Ref> @@ -238,7 +254,8 @@ export default plugin(workbenchId, { NavigationExpandedDefault: '' as Metadata }, extensions: { - WorkbenchExtensions: '' as ComponentExtensionId + WorkbenchExtensions: '' as ComponentExtensionId, + WorkbenchTabExtensions: '' as ComponentExtensionId }, actionImpl: { Navigate: '' as ViewAction<{