UBER-413: Allow extensible navigator model (#3477)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-07-04 08:50:24 +07:00 committed by GitHub
parent 03b9be571b
commit 2869a8e189
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 214 additions and 44 deletions

View File

@ -149,6 +149,7 @@ export function createModel (builder: Builder): void {
navigatorModel: {
spaces: [
{
id: 'boards',
label: board.string.MyBoards,
spaceClass: board.class.Board,
addSpaceLabel: board.string.BoardCreateLabel,

View File

@ -421,12 +421,14 @@ export function createModel (builder: Builder, options = { addApplication: true
],
spaces: [
{
id: 'channels',
label: chunter.string.Channels,
spaceClass: chunter.class.Channel,
addSpaceLabel: chunter.string.CreateChannel,
createComponent: chunter.component.CreateChannel
},
{
id: 'directMessages',
label: chunter.string.DirectMessages,
spaceClass: chunter.class.DirectMessage,
addSpaceLabel: chunter.string.NewDirectMessage,

View File

@ -163,6 +163,7 @@ export function createModel (builder: Builder): void {
],
spaces: [
{
id: 'funnels',
label: lead.string.Funnels,
spaceClass: lead.class.Funnel,
addSpaceLabel: lead.string.CreateFunnel,

View File

@ -543,7 +543,7 @@ export function createModel (builder: Builder): void {
{
key: 'assignee',
presenter: tracker.component.AssigneeEditor,
displayProps: { key: 'assigee', fixed: 'right' },
displayProps: { key: 'assignee', fixed: 'right' },
props: { kind: 'list', shouldShowName: false, avatarSize: 'x-small' }
}
],
@ -1084,6 +1084,7 @@ export function createModel (builder: Builder): void {
],
spaces: [
{
id: 'projects',
label: tracker.string.Projects,
spaceClass: tracker.class.Project,
addSpaceLabel: tracker.string.CreateProject,

View File

@ -19,7 +19,13 @@ import preference, { TPreference } from '@hcengineering/model-preference'
import { createAction } from '@hcengineering/model-view'
import type { Asset, IntlString } from '@hcengineering/platform'
import view, { KeyBinding } from '@hcengineering/view'
import type { Application, HiddenApplication, SpaceView, ViewConfiguration } from '@hcengineering/workbench'
import type {
Application,
ApplicationNavModel,
HiddenApplication,
SpaceView,
ViewConfiguration
} from '@hcengineering/workbench'
import core, { TClass, TDoc } from '@hcengineering/model-core'
import workbench from './plugin'
@ -37,6 +43,12 @@ export class TApplication extends TDoc implements Application {
hidden!: boolean
}
@Model(workbench.class.ApplicationNavModel, core.class.Doc, DOMAIN_MODEL)
@UX(workbench.string.Application)
export class TApplicationNavModel extends TDoc implements ApplicationNavModel {
extends!: Ref<Application>
}
@Model(workbench.class.HiddenApplication, preference.class.Preference)
export class THiddenApplication extends TPreference implements HiddenApplication {
@Prop(TypeRef(workbench.class.Application), workbench.string.HiddenApplication)
@ -49,7 +61,7 @@ export class TSpaceView extends TClass implements SpaceView {
}
export function createModel (builder: Builder): void {
builder.createModel(TApplication, TSpaceView, THiddenApplication)
builder.createModel(TApplication, TSpaceView, THiddenApplication, TApplicationNavModel)
builder.mixin(workbench.class.Application, core.class.Class, view.mixin.ObjectPresenter, {
presenter: workbench.component.ApplicationPresenter
})

View File

@ -1,3 +1,4 @@
import { deepEqual } from 'fast-equals'
import { DocumentUpdate, Hierarchy, MixinData, MixinUpdate, ModelDb, toFindResult } from '.'
import type {
Account,
@ -15,7 +16,7 @@ import type {
import { Client } from './client'
import core from './component'
import type { DocumentQuery, FindOptions, FindResult, TxResult, WithLookup } from './storage'
import { DocumentClassQuery, Tx, TxCUD, TxFactory } from './tx'
import { DocumentClassQuery, Tx, TxCUD, TxFactory, TxProcessor } from './tx'
/**
* @public
@ -269,6 +270,57 @@ export class TxOperations implements Omit<Client, 'notify'> {
apply (scope: string): ApplyOperations {
return new ApplyOperations(this, scope)
}
async diffUpdate (doc: Doc, raw: Doc | Data<Doc>, date: Timestamp): Promise<Doc> {
// We need to update fields if they are different.
const documentUpdate: DocumentUpdate<Doc> = {}
for (const [k, v] of Object.entries(raw)) {
if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) {
continue
}
const dv = (doc as any)[k]
if (!deepEqual(dv, v) && v != null) {
;(documentUpdate as any)[k] = v
}
}
if (Object.keys(documentUpdate).length > 0) {
await this.update(doc, documentUpdate, false, date, doc.modifiedBy)
TxProcessor.applyUpdate(doc, documentUpdate)
}
return doc
}
async mixinDiffUpdate (
doc: Doc,
raw: Doc | Data<Doc>,
mixin: Ref<Class<Mixin<Doc>>>,
modifiedBy: Ref<Account>,
modifiedOn: Timestamp
): Promise<Doc> {
// We need to update fields if they are different.
if (!this.getHierarchy().hasMixin(doc, mixin)) {
await this.createMixin(doc._id, doc._class, doc.space, mixin, raw as MixinData<Doc, Doc>, modifiedOn, modifiedBy)
TxProcessor.applyUpdate(this.getHierarchy().as(doc, mixin), raw)
return doc
}
const documentUpdate: MixinUpdate<Doc, Doc> = {}
for (const [k, v] of Object.entries(raw)) {
if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) {
continue
}
const dv = (doc as any)[k]
if (!deepEqual(dv, v) && v != null) {
;(documentUpdate as any)[k] = v
}
}
if (Object.keys(documentUpdate).length > 0) {
await this.updateMixin(doc._id, doc._class, doc.space, mixin, documentUpdate, modifiedOn, modifiedBy)
TxProcessor.applyUpdate(this.getHierarchy().as(doc, mixin), documentUpdate)
}
return doc
}
}
/**

View File

@ -14,6 +14,8 @@
import view, { Viewlet } from '@hcengineering/view'
import {
FilterBar,
SpaceHeader,
ViewletContentView,
ViewletSettingButton,
activeViewlet,
getViewOptions,
@ -23,8 +25,7 @@
} from '@hcengineering/view-resources'
import { onDestroy } from 'svelte'
import tracker from '../../plugin'
import IssuesContent from './IssuesContent.svelte'
import IssuesHeader from './IssuesHeader.svelte'
import CreateIssue from '../CreateIssue.svelte'
export let space: Ref<Space> | undefined = undefined
export let query: DocumentQuery<Issue> = {}
@ -88,7 +89,8 @@
$: viewOptions = getViewOptions(viewlet, $viewOptionStore)
</script>
<IssuesHeader
<SpaceHeader
_class={tracker.class.Issue}
bind:viewlet
bind:search
showLabelSelector={$$slots.label_selector}
@ -117,12 +119,21 @@
/>
{/if}
</svelte:fragment>
</IssuesHeader>
</SpaceHeader>
<FilterBar _class={tracker.class.Issue} query={searchQuery} {viewOptions} on:change={(e) => (resultQuery = e.detail)} />
<slot name="afterHeader" />
<div class="popupPanel rowContent">
{#if viewlet}
<IssuesContent {viewlet} query={resultQuery} {space} {viewOptions} />
<ViewletContentView
_class={tracker.class.Issue}
{viewlet}
query={resultQuery}
{space}
{viewOptions}
createItemDialog={CreateIssue}
createItemLabel={tracker.string.AddIssueTooltip}
createItemDialogProps={{ shouldSaveDraft: true }}
/>
{/if}
{#if $$slots.aside !== undefined && asideShown}
<div class="popupPanel-body__aside" class:shown={asideShown}>

View File

@ -23,9 +23,10 @@
themeStore
} from '@hcengineering/ui'
import { NavLink, TreeNode } from '@hcengineering/view-resources'
import { SpacesNavModel } from '@hcengineering/workbench'
import { SpacesNavModel, SpecialNavModel } from '@hcengineering/workbench'
import { SpecialElement } from '@hcengineering/workbench-resources'
import tracker from '../../plugin'
import { getResource } from '@hcengineering/platform'
export let space: Project
export let model: SpacesNavModel
@ -38,9 +39,30 @@
const getSpaceCollapsedKey = () => `${getCurrentLocation().path[1]}_${space._id}_collapsed`
$: collapsed = localStorage.getItem(getSpaceCollapsedKey()) === COLLAPSED
let specials: SpecialNavModel[] = []
async function updateSpecials (model: SpacesNavModel, space: Project): Promise<void> {
const newSpecials: SpecialNavModel[] = []
for (const sp of model.specials ?? []) {
let shouldAdd = true
if (sp.visibleIf !== undefined) {
const visibleIf = await getResource(sp.visibleIf)
if (visibleIf !== undefined) {
shouldAdd = await visibleIf([space])
}
}
if (shouldAdd) {
newSpecials.push(sp)
}
}
specials = newSpecials
}
$: updateSpecials(model, space)
</script>
{#if model.specials}
{#if specials}
<TreeNode
{collapsed}
icon={space?.icon === tracker.component.IconWithEmoji ? IconWithEmoji : space?.icon ?? model.icon}
@ -56,7 +78,7 @@
actions={() => getActions(space)}
on:click={() => localStorage.setItem(getSpaceCollapsedKey(), collapsed ? '' : COLLAPSED)}
>
{#each model.specials as special}
{#each specials as special}
<NavLink space={space._id} special={special.id}>
<SpecialElement
indent={'ml-2'}

View File

@ -15,6 +15,7 @@
import view, { Viewlet } from '@hcengineering/view'
import {
FilterBar,
SpaceHeader,
ViewletSettingButton,
activeViewlet,
getViewOptions,
@ -24,7 +25,6 @@
} from '@hcengineering/view-resources'
import { onDestroy } from 'svelte'
import tracker from '../../plugin'
import IssuesHeader from '../issues/IssuesHeader.svelte'
import CreateIssueTemplate from './CreateIssueTemplate.svelte'
import IssueTemplatesContent from './IssueTemplatesContent.svelte'
@ -91,7 +91,15 @@
$: viewOptions = getViewOptions(viewlet, $viewOptionStore)
</script>
<IssuesHeader {space} {viewlets} {label} bind:viewlet bind:search showLabelSelector={$$slots.label_selector}>
<SpaceHeader
_class={tracker.class.IssueTemplate}
{space}
{viewlets}
{label}
bind:viewlet
bind:search
showLabelSelector={$$slots.label_selector}
>
<svelte:fragment slot="label_selector">
<slot name="label_selector" />
</svelte:fragment>
@ -115,7 +123,7 @@
<ViewletSettingButton bind:viewOptions {viewlet} />
{/if}
</svelte:fragment>
</IssuesHeader>
</SpaceHeader>
<slot name="afterHeader" />
<FilterBar
_class={tracker.class.IssueTemplate}

View File

@ -1,12 +1,13 @@
<script lang="ts">
import { Ref, Space } from '@hcengineering/core'
import { Class, Doc, Ref, Space } from '@hcengineering/core'
import { TabList, SearchEdit, IModeSelector, ModeSelector } from '@hcengineering/ui'
import { Viewlet } from '@hcengineering/view'
import { FilterButton, setActiveViewletId } from '@hcengineering/view-resources'
import tracker from '../../plugin'
import { WithLookup } from '@hcengineering/core'
import { setActiveViewletId } from '../utils'
import FilterButton from './filter/FilterButton.svelte'
export let space: Ref<Space> | undefined = undefined
export let _class: Ref<Class<Doc>>
export let viewlet: WithLookup<Viewlet> | undefined
export let viewlets: WithLookup<Viewlet>[] = []
export let label: string
@ -65,7 +66,7 @@
<SearchEdit bind:value={search} on:change={() => {}} />
<!-- <ActionIcon icon={IconMoreH} size={'small'} /> -->
<div class="buttons-divider" />
<FilterButton _class={tracker.class.Issue} {space} />
<FilterButton {_class} {space} />
</div>
<div class="ac-header-full medium-gap">
<slot name="extra" />

View File

@ -1,18 +1,21 @@
<script lang="ts">
import { DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core'
import { Issue } from '@hcengineering/tracker'
import { Component, Loading } from '@hcengineering/ui'
import { Class, Doc, DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core'
import { AnySvelteComponent, Component, Loading } from '@hcengineering/ui'
import view, { Viewlet, ViewletPreference, ViewOptions } from '@hcengineering/view'
import tracker from '../../plugin'
import CreateIssue from '../CreateIssue.svelte'
import { createQuery } from '@hcengineering/presentation'
import { IntlString } from '@hcengineering/platform'
export let viewlet: WithLookup<Viewlet>
export let query: DocumentQuery<Issue> = {}
export let _class: Ref<Class<Doc>>
export let query: DocumentQuery<Doc> = {}
export let space: Ref<Space> | undefined
export let viewOptions: ViewOptions
export let createItemDialog: AnySvelteComponent | undefined = undefined
export let createItemLabel: IntlString | undefined = undefined
export let createItemDialogProps = { shouldSaveDraft: true }
const preferenceQuery = createQuery()
let preference: ViewletPreference | undefined
let loading = true
@ -29,10 +32,6 @@
},
{ limit: 1 }
)
const createItemDialog = CreateIssue
const createItemLabel = tracker.string.AddIssueTooltip
const createItemDialogProps = { shouldSaveDraft: true }
</script>
{#if viewlet?.$lookup?.descriptor?.component}
@ -42,7 +41,7 @@
<Component
is={viewlet.$lookup.descriptor.component}
props={{
_class: tracker.class.Issue,
_class,
config: preference?.config ?? viewlet.config,
options: viewlet.options,
createItemDialog,

View File

@ -77,6 +77,8 @@ import ValueSelector from './components/ValueSelector.svelte'
import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import DateFilterPresenter from './components/filter/DateFilterPresenter.svelte'
import ArrayFilter from './components/filter/ArrayFilter.svelte'
import SpaceHeader from './components/SpaceHeader.svelte'
import ViewletContentView from './components/ViewletContentView.svelte'
import {
afterResult,
@ -175,7 +177,9 @@ export {
DocNavLink,
EnumEditor,
StringPresenter,
EditBoxPopup
EditBoxPopup,
SpaceHeader,
ViewletContentView
}
function PositionElementAlignment (e?: Event): PopupAlignment | undefined {

View File

@ -63,7 +63,7 @@
import { getContext, onDestroy, onMount, tick } from 'svelte'
import { subscribeMobile } from '../mobile'
import workbench from '../plugin'
import { workspacesStore } from '../utils'
import { buildNavModel, workspacesStore } from '../utils'
import AccountPopup from './AccountPopup.svelte'
import AppItem from './AppItem.svelte'
import AppSwitcher from './AppSwitcher.svelte'
@ -326,7 +326,7 @@
clear(1)
currentAppAlias = app
currentApplication = await client.findOne(workbench.class.Application, { alias: app })
navigatorModel = currentApplication?.navigatorModel
navigatorModel = await buildNavModel(client, currentApplication)
}
if (

View File

@ -17,11 +17,12 @@
import core from '@hcengineering/core'
import { DocUpdates } from '@hcengineering/notification'
import { NotificationClientImpl } from '@hcengineering/notification-resources'
import { getResource } from '@hcengineering/platform'
import { IntlString, getResource } from '@hcengineering/platform'
import preference from '@hcengineering/preference'
import { getClient } from '@hcengineering/presentation'
import {
Action,
AnyComponent,
AnySvelteComponent,
IconAdd,
IconEdit,
@ -49,14 +50,14 @@
const client = getClient()
const dispatch = createEventDispatcher()
const addSpace: Action = {
label: model.addSpaceLabel,
const addSpace = (addSpaceLabel: IntlString, createComponent: AnyComponent): Action => ({
label: addSpaceLabel,
icon: IconAdd,
action: async (_id: Ref<Doc>): Promise<void> => {
dispatch('open')
showPopup(model.createComponent, {}, 'top')
}
showPopup(createComponent, {}, 'top')
}
})
const browseSpaces: Action = {
label: plugin.string.BrowseSpaces,
@ -108,7 +109,11 @@
}
function getParentActions (): Action[] {
return hasSpaceBrowser ? [browseSpaces, addSpace] : [addSpace]
const result = hasSpaceBrowser ? [browseSpaces] : []
if (model.addSpaceLabel !== undefined && model.createComponent !== undefined) {
result.push(addSpace(model.addSpaceLabel, model.createComponent))
}
return result
}
async function getPresenter (_class: Ref<Class<Doc>>): Promise<AnySvelteComponent | undefined> {

View File

@ -14,7 +14,7 @@
// limitations under the License.
//
import type { Class, Client, Doc, Obj, Ref, Space } from '@hcengineering/core'
import type { Class, Client, Doc, Obj, Ref, Space, TxOperations } from '@hcengineering/core'
import core from '@hcengineering/core'
import type { Workspace } from '@hcengineering/login'
import type { Asset } from '@hcengineering/platform'
@ -146,3 +146,38 @@ export async function showApplication (app: Application): Promise<void> {
}
export const workspacesStore = writable<Workspace[]>([])
/**
* @public
*/
export async function buildNavModel (
client: TxOperations,
currentApplication?: Application
): Promise<NavigatorModel | undefined> {
let newNavModel = currentApplication?.navigatorModel
if (currentApplication !== undefined) {
const models = await client.findAll(workbench.class.ApplicationNavModel, { extends: currentApplication._id })
for (const nm of models) {
const spaces = newNavModel?.spaces ?? []
// Check for extending
for (const sp of spaces) {
const extend = (nm.spaces ?? []).find((p) => p.id === sp.id)
if (extend !== undefined) {
sp.label = sp.label ?? extend.label
sp.createComponent = sp.createComponent ?? extend.createComponent
sp.addSpaceLabel = sp.addSpaceLabel ?? extend.addSpaceLabel
sp.icon = sp.icon ?? extend.icon
sp.visibleIf = sp.visibleIf ?? extend.visibleIf
sp.specials = [...(sp.specials ?? []), ...(extend.specials ?? [])]
}
}
const newSpaces = (nm.spaces ?? []).filter((it) => !spaces.some((sp) => sp.id === it.id))
newNavModel = {
spaces: [...spaces, ...newSpaces],
specials: [...(newNavModel?.specials ?? []), ...(nm.specials ?? [])],
aside: newNavModel?.aside ?? nm?.aside
}
}
}
return newNavModel
}

View File

@ -16,9 +16,9 @@
import type { Class, Doc, Mixin, Obj, Ref, Space } from '@hcengineering/core'
import type { Asset, IntlString, Metadata, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { Preference } from '@hcengineering/preference'
import { AnyComponent, Location, ResolvedLocation } from '@hcengineering/ui'
import { ViewAction } from '@hcengineering/view'
import type { Preference } from '@hcengineering/preference'
/**
* @public
@ -28,7 +28,10 @@ export interface Application extends Doc {
alias: string
icon: Asset
hidden: boolean
// Also attached ApplicationNavModel will be joined after this one main.
navigatorModel?: NavigatorModel
locationResolver?: Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>
// Component will be displayed in case navigator model is not defined, or nothing is selected in navigator model
@ -40,6 +43,17 @@ export interface Application extends Doc {
navFooterComponent?: AnyComponent
}
/**
* @public
*/
export interface ApplicationNavModel extends Doc {
extends: Ref<Application>
spaces?: SpacesNavModel[]
specials?: SpecialNavModel[]
aside?: AnyComponent
}
/**
* @public
*/
@ -51,10 +65,11 @@ export interface HiddenApplication extends Preference {
* @public
*/
export interface SpacesNavModel {
label: IntlString
id: string // Id could be used for extending of navigation model
label?: IntlString
spaceClass: Ref<Class<Space>>
addSpaceLabel: IntlString
createComponent: AnyComponent
addSpaceLabel?: IntlString
createComponent?: AnyComponent
icon?: Asset
// Child special items.
@ -118,6 +133,7 @@ export const workbenchId = 'workbench' as Plugin
export default plugin(workbenchId, {
class: {
Application: '' as Ref<Class<Application>>,
ApplicationNavModel: '' as Ref<Class<ApplicationNavModel>>,
HiddenApplication: '' as Ref<Class<HiddenApplication>>
},
mixin: {