mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-30 12:20:00 +00:00
Merge remote-tracking branch 'origin/develop' into staging
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
commit
94b8a71997
@ -420,7 +420,9 @@ export async function configurePlatform() {
|
||||
setMetadata(login.metadata.TransactorOverride, config.TRANSACTOR_OVERRIDE)
|
||||
|
||||
// Use binary response transfer for faster performance and small transfer sizes.
|
||||
setMetadata(client.metadata.UseBinaryProtocol, config.USE_BINARY_PROTOCOL ?? true)
|
||||
const binaryOverride = localStorage.getItem(client.metadata.UseBinaryProtocol)
|
||||
setMetadata(client.metadata.UseBinaryProtocol, binaryOverride != null ? binaryOverride === 'true' : (config.USE_BINARY_PROTOCOL ?? true))
|
||||
|
||||
// Disable for now, since it causes performance issues on linux/docker/kubernetes boxes for now.
|
||||
setMetadata(client.metadata.UseProtocolCompression, true)
|
||||
|
||||
|
@ -160,7 +160,6 @@
|
||||
"csv-parse": "~5.1.0",
|
||||
"email-addresses": "^5.0.0",
|
||||
"fast-equals": "^5.0.1",
|
||||
"got": "^11.8.3",
|
||||
"libphonenumber-js": "^1.9.46",
|
||||
"mime-types": "~2.1.34",
|
||||
"mongodb": "^6.12.0",
|
||||
|
@ -69,7 +69,7 @@ import {
|
||||
registerTxAdapterFactory
|
||||
} from '@hcengineering/server-pipeline'
|
||||
import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token'
|
||||
import { FileModelLogger } from '@hcengineering/server-tool'
|
||||
import { FileModelLogger, buildModel } from '@hcengineering/server-tool'
|
||||
import { createWorkspace, upgradeWorkspace } from '@hcengineering/workspace-service'
|
||||
import path from 'path'
|
||||
|
||||
@ -143,7 +143,7 @@ import {
|
||||
moveFromMongoToPG,
|
||||
moveWorkspaceFromMongoToPG
|
||||
} from './db'
|
||||
import { restoreControlledDocContentMongo, restoreWikiContentMongo } from './markup'
|
||||
import { restoreControlledDocContentMongo, restoreWikiContentMongo, restoreMarkupRefsMongo } from './markup'
|
||||
import { fixMixinForeignAttributes, showMixinForeignAttributes } from './mixin'
|
||||
import { fixAccountEmails, renameAccount } from './renameAccount'
|
||||
import { copyToDatalake, moveFiles, showLostFiles } from './storage'
|
||||
@ -1345,6 +1345,61 @@ export function devTool (
|
||||
})
|
||||
})
|
||||
|
||||
program
|
||||
.command('restore-markup-ref-mongo')
|
||||
.description('restore markup document content refs')
|
||||
.option('-w, --workspace <workspace>', 'Selected workspace only', '')
|
||||
.option('-f, --force', 'Force update', false)
|
||||
.action(async (cmd: { workspace: string, force: boolean }) => {
|
||||
const { txes, version } = prepareTools()
|
||||
|
||||
const { hierarchy } = await buildModel(toolCtx, txes)
|
||||
|
||||
let workspaces: Workspace[] = []
|
||||
await withAccountDatabase(async (db) => {
|
||||
workspaces = await listWorkspacesPure(db)
|
||||
workspaces = workspaces
|
||||
.filter((p) => isActiveMode(p.mode))
|
||||
.filter((p) => cmd.workspace === '' || p.workspace === cmd.workspace)
|
||||
.sort((a, b) => b.lastVisit - a.lastVisit)
|
||||
})
|
||||
|
||||
console.log('found workspaces', workspaces.length)
|
||||
|
||||
await withStorage(async (storageAdapter) => {
|
||||
const mongodbUri = getMongoDBUrl()
|
||||
const client = getMongoClient(mongodbUri)
|
||||
const _client = await client.getClient()
|
||||
|
||||
try {
|
||||
const count = workspaces.length
|
||||
let index = 0
|
||||
for (const workspace of workspaces) {
|
||||
index++
|
||||
|
||||
toolCtx.info('processing workspace', {
|
||||
workspace: workspace.workspace,
|
||||
version: workspace.version,
|
||||
index,
|
||||
count
|
||||
})
|
||||
|
||||
if (!cmd.force && (workspace.version === undefined || !deepEqual(workspace.version, version))) {
|
||||
console.log(`upgrade to ${versionToString(version)} is required`)
|
||||
continue
|
||||
}
|
||||
|
||||
const workspaceId = getWorkspaceId(workspace.workspace)
|
||||
const wsDb = getWorkspaceMongoDB(_client, { name: workspace.workspace })
|
||||
|
||||
await restoreMarkupRefsMongo(toolCtx, wsDb, workspaceId, hierarchy, storageAdapter)
|
||||
}
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
program
|
||||
.command('confirm-email <email>')
|
||||
.description('confirm user email')
|
||||
|
@ -13,10 +13,17 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { loadCollabYdoc, saveCollabYdoc, yDocCopyXmlField } from '@hcengineering/collaboration'
|
||||
import {
|
||||
loadCollabYdoc,
|
||||
saveCollabJson,
|
||||
saveCollabYdoc,
|
||||
yDocCopyXmlField,
|
||||
yDocFromBuffer
|
||||
} from '@hcengineering/collaboration'
|
||||
import core, {
|
||||
type Blob,
|
||||
type Doc,
|
||||
type Hierarchy,
|
||||
type MeasureContext,
|
||||
type Ref,
|
||||
type TxCreateDoc,
|
||||
@ -24,6 +31,7 @@ import core, {
|
||||
type WorkspaceId,
|
||||
DOMAIN_TX,
|
||||
SortingOrder,
|
||||
makeCollabId,
|
||||
makeCollabYdocId,
|
||||
makeDocCollabId
|
||||
} from '@hcengineering/core'
|
||||
@ -290,3 +298,65 @@ export async function restoreControlledDocContentForDoc (
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function restoreMarkupRefsMongo (
|
||||
ctx: MeasureContext,
|
||||
db: Db,
|
||||
workspaceId: WorkspaceId,
|
||||
hierarchy: Hierarchy,
|
||||
storageAdapter: StorageAdapter
|
||||
): Promise<void> {
|
||||
const classes = hierarchy.getDescendants(core.class.Doc)
|
||||
for (const _class of classes) {
|
||||
const domain = hierarchy.findDomain(_class)
|
||||
if (domain === undefined) continue
|
||||
|
||||
const allAttributes = hierarchy.getAllAttributes(_class)
|
||||
const attributes = Array.from(allAttributes.values()).filter((attribute) => {
|
||||
return hierarchy.isDerived(attribute.type._class, core.class.TypeCollaborativeDoc)
|
||||
})
|
||||
|
||||
if (attributes.length === 0) continue
|
||||
if (hierarchy.isMixin(_class) && attributes.every((p) => p.attributeOf !== _class)) continue
|
||||
|
||||
ctx.info('processing', { _class, attributes: attributes.map((p) => p.name) })
|
||||
|
||||
const collection = db.collection<Doc>(domain)
|
||||
const iterator = collection.find({ _class })
|
||||
try {
|
||||
while (true) {
|
||||
const doc = await iterator.next()
|
||||
if (doc === null) {
|
||||
break
|
||||
}
|
||||
|
||||
for (const attribute of attributes) {
|
||||
const isMixin = hierarchy.isMixin(attribute.attributeOf)
|
||||
|
||||
const attributeName = isMixin ? `${attribute.attributeOf}.${attribute.name}` : attribute.name
|
||||
|
||||
const value = isMixin
|
||||
? ((doc as any)[attribute.attributeOf]?.[attribute.name] as string)
|
||||
: ((doc as any)[attribute.name] as string)
|
||||
|
||||
if (typeof value === 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
const collabId = makeCollabId(doc._class, doc._id, attribute.name)
|
||||
const ydocId = makeCollabYdocId(collabId)
|
||||
|
||||
try {
|
||||
const buffer = await storageAdapter.read(ctx, workspaceId, ydocId)
|
||||
const ydoc = yDocFromBuffer(Buffer.concat(buffer as any))
|
||||
|
||||
const jsonId = await saveCollabJson(ctx, storageAdapter, workspaceId, collabId, ydoc)
|
||||
await collection.updateOne({ _id: doc._id }, { $set: { [attributeName]: jsonId } })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await iterator.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -213,7 +213,8 @@ export function createModel (builder: Builder): void {
|
||||
['assigned', view.string.Assigned, {}],
|
||||
['created', view.string.Created, {}],
|
||||
['subscribed', view.string.Subscribed, {}]
|
||||
]
|
||||
],
|
||||
descriptors: [view.viewlet.List, view.viewlet.Table, task.viewlet.Kanban]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -510,6 +510,14 @@ export function createModel (builder: Builder): void {
|
||||
value: true
|
||||
})
|
||||
|
||||
builder.mixin(tracker.class.Milestone, core.class.Class, setting.mixin.Editable, {
|
||||
value: true
|
||||
})
|
||||
|
||||
builder.mixin(tracker.class.Component, core.class.Class, setting.mixin.Editable, {
|
||||
value: true
|
||||
})
|
||||
|
||||
builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.LinkProvider, {
|
||||
encode: tracker.function.GetIssueLinkFragment
|
||||
})
|
||||
|
@ -661,6 +661,7 @@ export type WorkspaceMode =
|
||||
| 'pending-deletion' // -> 'deleting'
|
||||
| 'deleting' // -> "deleted"
|
||||
| 'active'
|
||||
| 'deleted'
|
||||
| 'archiving-pending-backup' // -> 'cleaning'
|
||||
| 'archiving-backup' // -> 'archiving-pending-clean'
|
||||
| 'archiving-pending-clean' // -> 'archiving-clean'
|
||||
|
@ -21,12 +21,10 @@ import { Hierarchy } from './hierarchy'
|
||||
import { MeasureContext, MeasureMetricsContext } from './measurements'
|
||||
import { ModelDb } from './memdb'
|
||||
import type { DocumentQuery, FindOptions, FindResult, FulltextStorage, Storage, TxResult, WithLookup } from './storage'
|
||||
import { SearchOptions, SearchQuery, SearchResult, SortingOrder } from './storage'
|
||||
import { Tx, TxCUD } from './tx'
|
||||
import { SearchOptions, SearchQuery, SearchResult } from './storage'
|
||||
import { Tx, TxCUD, type TxWorkspaceEvent } from './tx'
|
||||
import { toFindResult } from './utils'
|
||||
|
||||
const transactionThreshold = 500
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -85,11 +83,13 @@ export interface ClientConnection extends Storage, FulltextStorage, BackupClient
|
||||
isConnected: () => boolean
|
||||
|
||||
close: () => Promise<void>
|
||||
onConnect?: (event: ClientConnectEvent, data: any) => Promise<void>
|
||||
onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise<void>
|
||||
|
||||
// If hash is passed, will return LoadModelResponse
|
||||
loadModel: (last: Timestamp, hash?: string) => Promise<Tx[] | LoadModelResponse>
|
||||
getAccount: () => Promise<Account>
|
||||
|
||||
getLastHash?: (ctx: MeasureContext) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
class ClientImpl implements AccountClient, BackupClient {
|
||||
@ -236,7 +236,7 @@ export async function createClient (
|
||||
let hierarchy = new Hierarchy()
|
||||
let model = new ModelDb(hierarchy)
|
||||
|
||||
let lastTx: number = 0
|
||||
let lastTx: string | undefined
|
||||
|
||||
function txHandler (...tx: Tx[]): void {
|
||||
if (tx == null || tx.length === 0) {
|
||||
@ -248,7 +248,11 @@ export async function createClient (
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
client.updateFromRemote(...tx)
|
||||
}
|
||||
lastTx = tx.reduce((cur, it) => (it.modifiedOn > cur ? it.modifiedOn : cur), 0)
|
||||
for (const t of tx) {
|
||||
if (t._class === core.class.TxWorkspaceEvent) {
|
||||
lastTx = (t as TxWorkspaceEvent).params.lastTx
|
||||
}
|
||||
}
|
||||
}
|
||||
const conn = await ctx.with('connect', {}, () => connect(txHandler))
|
||||
|
||||
@ -264,11 +268,14 @@ export async function createClient (
|
||||
txHandler(...txBuffer)
|
||||
txBuffer = undefined
|
||||
|
||||
const oldOnConnect: ((event: ClientConnectEvent, data: any) => Promise<void>) | undefined = conn.onConnect
|
||||
conn.onConnect = async (event, data) => {
|
||||
const oldOnConnect:
|
||||
| ((event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise<void>)
|
||||
| undefined = conn.onConnect
|
||||
conn.onConnect = async (event, _lastTx, data) => {
|
||||
console.log('Client: onConnect', event)
|
||||
if (event === ClientConnectEvent.Maintenance) {
|
||||
await oldOnConnect?.(ClientConnectEvent.Maintenance, data)
|
||||
lastTx = _lastTx
|
||||
await oldOnConnect?.(ClientConnectEvent.Maintenance, _lastTx, data)
|
||||
return
|
||||
}
|
||||
// Find all new transactions and apply
|
||||
@ -282,51 +289,27 @@ export async function createClient (
|
||||
model = new ModelDb(hierarchy)
|
||||
|
||||
await ctx.with('build-model', {}, (ctx) => buildModel(ctx, loadModelResponse, modelFilter, hierarchy, model))
|
||||
await oldOnConnect?.(ClientConnectEvent.Upgraded, data)
|
||||
|
||||
await oldOnConnect?.(ClientConnectEvent.Upgraded, _lastTx, data)
|
||||
lastTx = _lastTx
|
||||
// No need to fetch more stuff since upgrade was happened.
|
||||
return
|
||||
}
|
||||
|
||||
if (event === ClientConnectEvent.Connected) {
|
||||
if (event === ClientConnectEvent.Connected && _lastTx !== lastTx && lastTx === undefined) {
|
||||
// No need to do anything here since we connected.
|
||||
await oldOnConnect?.(event, data)
|
||||
await oldOnConnect?.(event, _lastTx, data)
|
||||
lastTx = _lastTx
|
||||
return
|
||||
}
|
||||
|
||||
// We need to look for last {transactionThreshold} transactions and if it is more since lastTx one we receive, we need to perform full refresh.
|
||||
if (lastTx === 0) {
|
||||
await oldOnConnect?.(ClientConnectEvent.Refresh, data)
|
||||
if (_lastTx === lastTx) {
|
||||
// Same lastTx, no need to refresh
|
||||
await oldOnConnect?.(ClientConnectEvent.Reconnected, _lastTx, data)
|
||||
return
|
||||
}
|
||||
const atxes = await ctx.with('find-atx', {}, () =>
|
||||
conn.findAll(
|
||||
core.class.Tx,
|
||||
{ modifiedOn: { $gt: lastTx }, objectSpace: { $ne: core.space.Model } },
|
||||
{ sort: { modifiedOn: SortingOrder.Ascending, _id: SortingOrder.Ascending }, limit: transactionThreshold }
|
||||
)
|
||||
)
|
||||
|
||||
let needFullRefresh = false
|
||||
// if we have attachment document create/delete we need to full refresh, since some derived data could be missing
|
||||
for (const tx of atxes) {
|
||||
if (
|
||||
(tx as TxCUD<Doc>).attachedTo !== undefined &&
|
||||
(tx._class === core.class.TxCreateDoc || tx._class === core.class.TxRemoveDoc)
|
||||
) {
|
||||
needFullRefresh = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (atxes.length < transactionThreshold && !needFullRefresh) {
|
||||
console.log('applying input transactions', atxes.length)
|
||||
txHandler(...atxes)
|
||||
await oldOnConnect?.(ClientConnectEvent.Reconnected, data)
|
||||
} else {
|
||||
// We need to trigger full refresh on queries, etc.
|
||||
await oldOnConnect?.(ClientConnectEvent.Refresh, data)
|
||||
}
|
||||
lastTx = _lastTx
|
||||
// We need to trigger full refresh on queries, etc.
|
||||
await oldOnConnect?.(ClientConnectEvent.Refresh, lastTx, data)
|
||||
}
|
||||
|
||||
return client
|
||||
@ -344,6 +327,10 @@ async function tryLoadModel (
|
||||
hash: ''
|
||||
}
|
||||
|
||||
if (conn.getLastHash !== undefined && (await conn.getLastHash(ctx)) === current.hash) {
|
||||
// We have same model hash.
|
||||
return current
|
||||
}
|
||||
const lastTxTime = getLastTxTime(current.transactions)
|
||||
const result = await ctx.with('connection-load-model', { hash: current.hash !== '' }, (ctx) =>
|
||||
conn.loadModel(lastTxTime, current.hash)
|
||||
@ -357,21 +344,23 @@ async function tryLoadModel (
|
||||
hash: ''
|
||||
}
|
||||
}
|
||||
|
||||
// Save concatenated
|
||||
void ctx
|
||||
.with('persistence-store', {}, (ctx) =>
|
||||
persistence?.store({
|
||||
...result,
|
||||
transactions: !result.full ? current.transactions.concat(result.transactions) : result.transactions
|
||||
const transactions = current.transactions.concat(result.transactions)
|
||||
if (result.hash !== current.hash) {
|
||||
// Save concatenated, if have some more of them.
|
||||
void ctx
|
||||
.with('persistence-store', {}, (ctx) =>
|
||||
persistence?.store({
|
||||
...result,
|
||||
transactions: !result.full ? transactions : result.transactions
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
Analytics.handleError(err)
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
Analytics.handleError(err)
|
||||
})
|
||||
}
|
||||
|
||||
if (!result.full && !reload) {
|
||||
result.transactions = current.transactions.concat(result.transactions)
|
||||
result.transactions = transactions
|
||||
}
|
||||
|
||||
return result
|
||||
|
@ -51,7 +51,8 @@ export enum WorkspaceEvent {
|
||||
IndexingUpdate,
|
||||
SecurityChange,
|
||||
MaintenanceNotification,
|
||||
BulkUpdate
|
||||
BulkUpdate,
|
||||
LastTx
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -140,7 +140,22 @@ export const storeNodes: Record<string, NodeProcessor> = {
|
||||
|
||||
image: (state, node) => {
|
||||
const attrs = nodeAttrs(node)
|
||||
if (attrs['file-id'] != null) {
|
||||
if (attrs.token != null && attrs['file-id'] != null) {
|
||||
// Convert image to token format
|
||||
state.write(
|
||||
' : '') +
|
||||
(attrs.height != null ? '&height=' + state.esc(`${attrs.height}`) : '') +
|
||||
(attrs.token != null ? '&token=' + state.esc(`${attrs.token}`) : '')) +
|
||||
(attrs.title != null ? ' ' + state.quote(`${attrs.title}`) : '') +
|
||||
')'
|
||||
)
|
||||
} else if (attrs['file-id'] != null) {
|
||||
// Convert image to fileid format
|
||||
state.write(
|
||||
'![' +
|
||||
|
@ -172,6 +172,7 @@ table.proseTable {
|
||||
|
||||
&__selected {
|
||||
left: 0;
|
||||
|
||||
&::before {
|
||||
right: 0;
|
||||
top: 0;
|
||||
@ -203,8 +204,7 @@ table.proseTable {
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:not(.table-row-handle__selected) {
|
||||
}
|
||||
&:not(.table-row-handle__selected) {}
|
||||
|
||||
button {
|
||||
opacity: 1;
|
||||
@ -213,6 +213,7 @@ table.proseTable {
|
||||
|
||||
&__selected {
|
||||
top: 0;
|
||||
|
||||
&::before {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
@ -246,6 +247,27 @@ table.proseTable {
|
||||
}
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
top: -1px;
|
||||
bottom: -1px;
|
||||
width: 1px;
|
||||
z-index: 100;
|
||||
background-color: var(--primary-button-focused);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -5px;
|
||||
right: -5px;
|
||||
bottom: 0;
|
||||
cursor: col-resize;
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.table-row-insert {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -291,13 +313,13 @@ table.proseTable {
|
||||
}
|
||||
|
||||
&:hover+.table-insert-marker {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.table-insert-marker {
|
||||
background-color: var(--primary-button-focused);
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -526,7 +548,7 @@ pre.proseCodeBlock>pre.proseCode {
|
||||
border-bottom: 2px solid rgba(255, 203, 0, .35);
|
||||
padding-bottom: 2px;
|
||||
transition: background 0.2s ease, border 0.2s ease;
|
||||
|
||||
|
||||
&.active {
|
||||
transition-delay: 150ms;
|
||||
background: rgba(255, 203, 0, .24);
|
||||
|
@ -50,9 +50,11 @@
|
||||
let container: HTMLElement
|
||||
let opened: boolean = false
|
||||
|
||||
$: selectedItem = multiselect ? items.filter((p) => selected?.includes(p.id)) : items.find((x) => x.id === selected)
|
||||
$: if (autoSelect && selected === undefined && items[0] !== undefined) {
|
||||
selected = multiselect ? [items[0].id] : items[0].id
|
||||
$: selectedItem = multiselect
|
||||
? (items ?? []).filter((p) => selected?.includes(p.id))
|
||||
: (items ?? []).find((x) => x.id === selected)
|
||||
$: if (autoSelect && selected === undefined && items?.[0] !== undefined) {
|
||||
selected = multiselect ? [items?.[0]?.id] : items?.[0]?.id
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@ -111,7 +113,7 @@
|
||||
<slot name="content" />
|
||||
{:else if Array.isArray(selectedItem)}
|
||||
{#if selectedItem.length > 0}
|
||||
{#each selectedItem as seleceted, i}
|
||||
{#each selectedItem as seleceted}
|
||||
<span class="step-row">{seleceted.label}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
|
@ -504,6 +504,27 @@
|
||||
if (parentElement != null && typeof float === 'string') parentElement.setAttribute('data-float', float)
|
||||
}
|
||||
|
||||
const clearContainer = (container: HTMLElement): void => {
|
||||
if (container === null) return
|
||||
if (container.hasAttribute('data-float')) container.removeAttribute('data-float')
|
||||
if (container.hasAttribute('data-size')) container.removeAttribute('data-size')
|
||||
container.style.width = ''
|
||||
container.style.minWidth = ''
|
||||
container.style.maxWidth = ''
|
||||
}
|
||||
const clearSibling = (): void => {
|
||||
if (separators != null && prevElement != null && separators[index].float !== undefined) {
|
||||
clearContainer(prevElement)
|
||||
}
|
||||
if (separators != null && nextElement != null && separators[index + 1].float !== undefined) {
|
||||
clearContainer(nextElement)
|
||||
}
|
||||
}
|
||||
const clearParent = (): void => {
|
||||
if (parentElement === null && separator != null) parentElement = separator.parentElement as HTMLElement
|
||||
if (parentElement != null && typeof float === 'string') clearContainer(parentElement)
|
||||
}
|
||||
|
||||
const calculateSeparators = (): void => {
|
||||
if (parentElement != null) {
|
||||
const elements: Element[] = Array.from(parentElement.children)
|
||||
@ -600,6 +621,10 @@
|
||||
}
|
||||
})
|
||||
onDestroy(() => {
|
||||
if (mounted) {
|
||||
if (sState === SeparatorState.FLOAT) clearParent()
|
||||
else if (sState === SeparatorState.NORMAL) clearSibling()
|
||||
}
|
||||
window.removeEventListener('resize', resizeDocument)
|
||||
if (sState !== SeparatorState.FLOAT && $separatorsStore.filter((f) => f === name).length > 0) {
|
||||
$separatorsStore = $separatorsStore.filter((f) => f !== name)
|
||||
|
@ -140,7 +140,7 @@
|
||||
updateDeviceSize()
|
||||
|
||||
$: secondRow = checkAdaptiveMatching($deviceInfo.size, 'xs')
|
||||
$: asideFloat = $deviceInfo.aside.float
|
||||
$: asideFloat = checkAdaptiveMatching($deviceInfo.size, 'sm')
|
||||
$: asideOpen = $deviceInfo.aside.visible
|
||||
$: appsMini =
|
||||
$deviceInfo.isMobile &&
|
||||
|
@ -14,7 +14,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Attachment } from '@hcengineering/attachment'
|
||||
import { RateLimiter, Account, Class, Doc, IdMap, Markup, Ref, Space, generateId, toIdMap } from '@hcengineering/core'
|
||||
import { Account, Class, Doc, IdMap, Markup, RateLimiter, Ref, Space, generateId, toIdMap } from '@hcengineering/core'
|
||||
import { Asset, IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform'
|
||||
import {
|
||||
DraftController,
|
||||
@ -25,9 +25,9 @@
|
||||
getFileMetadata,
|
||||
uploadFile
|
||||
} from '@hcengineering/presentation'
|
||||
import { EmptyMarkup } from '@hcengineering/text'
|
||||
import textEditor, { type RefAction } from '@hcengineering/text-editor'
|
||||
import { AttachIcon, ReferenceInput } from '@hcengineering/text-editor-resources'
|
||||
import { EmptyMarkup } from '@hcengineering/text'
|
||||
import { Loading, type AnySvelteComponent } from '@hcengineering/ui'
|
||||
import { createEventDispatcher, onDestroy, tick } from 'svelte'
|
||||
import attachment from '../plugin'
|
||||
@ -196,11 +196,11 @@
|
||||
}
|
||||
|
||||
async function fileDrop (e: DragEvent): Promise<void> {
|
||||
progress = true
|
||||
const list = e.dataTransfer?.files
|
||||
const limiter = new RateLimiter(10)
|
||||
|
||||
if (list === undefined || list.length === 0) return
|
||||
progress = true
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const file = list.item(index)
|
||||
if (file !== null) {
|
||||
|
@ -108,10 +108,12 @@ class Connection implements ClientConnection {
|
||||
|
||||
private helloRecieved: boolean = false
|
||||
|
||||
onConnect?: (event: ClientConnectEvent, data: any) => Promise<void>
|
||||
onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise<void>
|
||||
|
||||
rpcHandler = new RPCHandler()
|
||||
|
||||
lastHash?: string
|
||||
|
||||
constructor (
|
||||
private readonly ctx: MeasureContext,
|
||||
private readonly url: string,
|
||||
@ -144,6 +146,11 @@ class Connection implements ClientConnection {
|
||||
this.scheduleOpen(this.ctx, false)
|
||||
}
|
||||
|
||||
async getLastHash (ctx: MeasureContext): Promise<string | undefined> {
|
||||
await this.waitOpenConnection(ctx)
|
||||
return this.lastHash
|
||||
}
|
||||
|
||||
private schedulePing (socketId: number): void {
|
||||
clearInterval(this.interval)
|
||||
this.pingResponse = Date.now()
|
||||
@ -272,7 +279,7 @@ class Connection implements ClientConnection {
|
||||
if (resp.id === -1) {
|
||||
this.delay = 0
|
||||
if (resp.result?.state === 'upgrading') {
|
||||
void this.onConnect?.(ClientConnectEvent.Maintenance, resp.result.stats)
|
||||
void this.onConnect?.(ClientConnectEvent.Maintenance, undefined, resp.result.stats)
|
||||
this.upgrading = true
|
||||
this.delay = 3
|
||||
return
|
||||
@ -286,6 +293,7 @@ class Connection implements ClientConnection {
|
||||
// We need to clear dial timer, since we recieve hello response.
|
||||
clearTimeout(this.dialTimer)
|
||||
this.dialTimer = null
|
||||
this.lastHash = (resp as HelloResponse).lastHash
|
||||
|
||||
const serverVersion = helloResp.serverVersion
|
||||
console.log('Connected to server:', serverVersion)
|
||||
@ -315,6 +323,7 @@ class Connection implements ClientConnection {
|
||||
|
||||
void this.onConnect?.(
|
||||
(resp as HelloResponse).reconnect === true ? ClientConnectEvent.Reconnected : ClientConnectEvent.Connected,
|
||||
(resp as HelloResponse).lastTx,
|
||||
this.sessionId
|
||||
)
|
||||
this.schedulePing(socketId)
|
||||
@ -549,6 +558,7 @@ class Connection implements ClientConnection {
|
||||
once?: boolean // Require handleResult to retrieve result
|
||||
measure?: (time: number, result: any, serverTime: number, queue: number, toRecieve: number) => void
|
||||
allowReconnect?: boolean
|
||||
overrideId?: number
|
||||
}): Promise<any> {
|
||||
return this.ctx.newChild('send-request', {}).with(data.method, {}, async (ctx) => {
|
||||
if (this.closed) {
|
||||
@ -566,7 +576,7 @@ class Connection implements ClientConnection {
|
||||
}
|
||||
}
|
||||
|
||||
const id = this.lastId++
|
||||
const id = data.overrideId ?? this.lastId++
|
||||
const promise = new RequestPromise(data.method, data.params, data.handleResult)
|
||||
promise.handleTime = data.measure
|
||||
|
||||
@ -725,7 +735,7 @@ class Connection implements ClientConnection {
|
||||
}
|
||||
|
||||
sendForceClose (): Promise<void> {
|
||||
return this.sendRequest({ method: 'forceClose', params: [], allowReconnect: false })
|
||||
return this.sendRequest({ method: 'forceClose', params: [], allowReconnect: false, overrideId: -2, once: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,10 +118,10 @@ export default async () => {
|
||||
reject(new Error(`Connection timeout, and no connection established to ${endpoint}`))
|
||||
}
|
||||
}, connectTimeout)
|
||||
newOpt.onConnect = async (event, data) => {
|
||||
newOpt.onConnect = async (event, lastTx, data) => {
|
||||
// Any event is fine, it means server is alive.
|
||||
clearTimeout(connectTO)
|
||||
await opt?.onConnect?.(event, data)
|
||||
await opt?.onConnect?.(event, lastTx, data)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
@ -62,7 +62,7 @@ export interface ClientFactoryOptions {
|
||||
onUpgrade?: () => void
|
||||
onUnauthorized?: () => void
|
||||
onArchived?: () => void
|
||||
onConnect?: (event: ClientConnectEvent, data: any) => Promise<void>
|
||||
onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise<void>
|
||||
ctx?: MeasureContext
|
||||
onDialTimeout?: () => void | Promise<void>
|
||||
}
|
||||
|
@ -13,13 +13,12 @@
|
||||
// limitations under the License.
|
||||
import documents, {
|
||||
documentsId,
|
||||
getDocumentId,
|
||||
type ControlledDocument,
|
||||
type Document,
|
||||
type Project,
|
||||
type ProjectDocument
|
||||
} from '@hcengineering/controlled-documents'
|
||||
import { type Client, type Doc, type Ref } from '@hcengineering/core'
|
||||
import { type Doc, type Ref } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { getCurrentResolvedLocation, getPanelURI, type Location, type ResolvedLocation } from '@hcengineering/ui'
|
||||
import view, { type ObjectPanel } from '@hcengineering/view'
|
||||
@ -159,13 +158,3 @@ export async function resolveLocation (loc: Location): Promise<ResolvedLocation
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function documentIdentifierProvider (client: Client, ref: Ref<Document>, doc?: Document): Promise<string> {
|
||||
const document = doc ?? (await client.findOne(documents.class.Document, { _id: ref }))
|
||||
|
||||
if (document === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return getDocumentId(document)
|
||||
}
|
||||
|
@ -50,8 +50,7 @@ import documents, {
|
||||
type ProjectMeta,
|
||||
ControlledDocumentState,
|
||||
DocumentState,
|
||||
getDocumentName,
|
||||
getDocumentId
|
||||
getDocumentName
|
||||
} from '@hcengineering/controlled-documents'
|
||||
import { type Request } from '@hcengineering/request'
|
||||
|
||||
@ -583,7 +582,7 @@ export async function documentIdentifierProvider (client: Client, ref: Ref<Docum
|
||||
return ''
|
||||
}
|
||||
|
||||
return getDocumentId(document)
|
||||
return document.code
|
||||
}
|
||||
|
||||
export function documentCompareFn (doc1: Document, doc2: Document): number {
|
||||
|
@ -31,7 +31,7 @@
|
||||
export let type: ObjectPresenterType = 'link'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
$: accentColor = getPlatformAvatarColorForTextDef(value.name, $themeStore.dark)
|
||||
$: accentColor = getPlatformAvatarColorForTextDef(value?.name ?? '', $themeStore.dark)
|
||||
|
||||
$: dispatch('accent-color', accentColor)
|
||||
onMount(() => {
|
||||
|
@ -67,9 +67,9 @@
|
||||
if (result.action === 'add') {
|
||||
void addRef(result.tag)
|
||||
} else if (result.action === 'remove') {
|
||||
const filtered = items.filter((it) => it.tag === result.tag._id)
|
||||
const filtered = items.filter((it) => (it.tag ? it.tag === result.tag._id : it._id === result.tag._id))
|
||||
if (filtered.length > 0) {
|
||||
void removeTag(filtered[0]._id)
|
||||
void removeTag(filtered[0].tag ?? filtered[0]._id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -82,7 +82,7 @@
|
||||
)
|
||||
}
|
||||
|
||||
async function removeTag (id: Ref<TagReference>): Promise<void> {
|
||||
async function removeTag (id: Ref<TagReference> | Ref<TagElement>): Promise<void> {
|
||||
dispatch('delete', id)
|
||||
}
|
||||
|
||||
|
@ -14,11 +14,10 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PersonAccount } from '@hcengineering/contact'
|
||||
import { Class, Doc, DocumentQuery, getCurrentAccount, Ref, Status } from '@hcengineering/core'
|
||||
import { Class, Doc, DocumentQuery, getCurrentAccount, Ref, Status, WithLookup } from '@hcengineering/core'
|
||||
import { IntlString, Asset } from '@hcengineering/platform'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import tags, { TagCategory, TagElement } from '@hcengineering/tags'
|
||||
import { selectedTagElements } from '@hcengineering/tags-resources'
|
||||
import { Task } from '@hcengineering/task'
|
||||
import {
|
||||
Component,
|
||||
@ -30,12 +29,11 @@
|
||||
SearchInput,
|
||||
Header
|
||||
} from '@hcengineering/ui'
|
||||
import { Viewlet, ViewletPreference, ViewOptions } from '@hcengineering/view'
|
||||
import { Viewlet, ViewletDescriptor, ViewletPreference, ViewOptions } from '@hcengineering/view'
|
||||
import {
|
||||
FilterBar,
|
||||
FilterButton,
|
||||
statusStore,
|
||||
TableBrowser,
|
||||
ViewletSelector,
|
||||
ViewletSettingButton
|
||||
} from '@hcengineering/view-resources'
|
||||
@ -46,6 +44,7 @@
|
||||
export let labelTasks = task.string.Tasks
|
||||
export let icon: Asset
|
||||
export let config: [string, IntlString, object][] = []
|
||||
export let descriptors: Ref<ViewletDescriptor>[] | undefined = undefined
|
||||
|
||||
let search = ''
|
||||
const dispatch = createEventDispatcher()
|
||||
@ -69,43 +68,22 @@
|
||||
|
||||
const client = getClient()
|
||||
|
||||
let category: Ref<TagCategory> | undefined = undefined
|
||||
const category: Ref<TagCategory> | undefined = undefined
|
||||
let loading = true
|
||||
let preference: ViewletPreference | undefined
|
||||
|
||||
let documentIds: Ref<Task>[] = []
|
||||
function updateResultQuery (
|
||||
search: string,
|
||||
documentIds: Ref<Task>[],
|
||||
doneStates: Status[],
|
||||
mode: string | undefined
|
||||
): void {
|
||||
function updateResultQuery (search: string, doneStates: Status[], mode: string | undefined): void {
|
||||
if (mode === 'assigned') {
|
||||
resultQuery.status = { $nin: doneStates.map((it) => it._id) }
|
||||
}
|
||||
if (documentIds.length > 0) {
|
||||
resultQuery._id = { $in: documentIds }
|
||||
}
|
||||
}
|
||||
|
||||
$: doneStates = $statusStore.array.filter(
|
||||
(it) => it.category === task.statusCategory.Lost || it.category === task.statusCategory.Won
|
||||
)
|
||||
|
||||
// Find all tags for object class with matched elements
|
||||
const query = createQuery()
|
||||
|
||||
$: query.query(tags.class.TagReference, { tag: { $in: $selectedTagElements } }, (result) => {
|
||||
documentIds = Array.from(
|
||||
new Set<Ref<Task>>(
|
||||
result
|
||||
.filter((it) => client.getHierarchy().isDerived(it.attachedToClass, _class))
|
||||
.map((it) => it.attachedTo as Ref<Task>)
|
||||
).values()
|
||||
)
|
||||
})
|
||||
const subscribedQuery = createQuery()
|
||||
function getSubscribed () {
|
||||
function getSubscribed (): void {
|
||||
subscribedQuery.query(
|
||||
_class,
|
||||
{ 'notification:mixin:Collaborators.collaborators': getCurrentAccount()._id },
|
||||
@ -132,31 +110,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: updateResultQuery(search, documentIds, doneStates, mode)
|
||||
$: updateResultQuery(search, doneStates, mode)
|
||||
|
||||
let viewlet: Viewlet | undefined
|
||||
let viewlet: WithLookup<Viewlet> | undefined
|
||||
|
||||
let viewOptions: ViewOptions | undefined
|
||||
|
||||
function updateCategory (detail: { category: Ref<TagCategory> | null, elements: TagElement[] }) {
|
||||
category = detail.category ?? undefined
|
||||
selectedTagElements.set(Array.from(detail.elements ?? []).map((it) => it._id))
|
||||
}
|
||||
const handleChange = (evt: any) => {
|
||||
updateCategory(evt.detail)
|
||||
}
|
||||
$: viewOptionsConfig =
|
||||
mode === 'assigned'
|
||||
? viewlet?.viewOptions?.other
|
||||
: (viewlet?.viewOptions?.other ?? []).filter((it) => it.actionTarget !== 'query')
|
||||
</script>
|
||||
|
||||
<Header adaptive={'freezeActions'} hideActions={modeSelectorProps === undefined}>
|
||||
<svelte:fragment slot="beforeTitle">
|
||||
<ViewletSelector
|
||||
hidden
|
||||
bind:viewlet
|
||||
bind:preference
|
||||
bind:loading
|
||||
viewletQuery={{ attachTo: _class, descriptor: task.viewlet.StatusTable }}
|
||||
viewletQuery={{
|
||||
attachTo: _class,
|
||||
variant: { $exists: false },
|
||||
...(descriptors !== undefined ? { descriptor: { $in: descriptors } } : {})
|
||||
}}
|
||||
/>
|
||||
<ViewletSettingButton bind:viewOptions bind:viewlet />
|
||||
<ViewletSettingButton bind:viewOptions bind:viewlet {viewOptionsConfig} />
|
||||
</svelte:fragment>
|
||||
|
||||
<Breadcrumb {icon} label={labelTasks} size={'large'} isCurrent />
|
||||
@ -166,7 +144,7 @@
|
||||
bind:value={search}
|
||||
collapsed
|
||||
on:change={() => {
|
||||
updateResultQuery(search, documentIds, doneStates, mode)
|
||||
updateResultQuery(search, doneStates, mode)
|
||||
}}
|
||||
/>
|
||||
<FilterButton {_class} adaptive={doubleRow} />
|
||||
@ -179,18 +157,19 @@
|
||||
</Header>
|
||||
<FilterBar {_class} query={searchQuery} space={undefined} {viewOptions} on:change={(e) => (resultQuery = e.detail)} />
|
||||
|
||||
<Component is={tags.component.TagsCategoryBar} props={{ targetClass: _class, category }} on:change={handleChange} />
|
||||
|
||||
{#if viewlet}
|
||||
{#if loading}
|
||||
<Loading />
|
||||
{:else}
|
||||
<TableBrowser
|
||||
{_class}
|
||||
config={preference?.config ?? viewlet.config}
|
||||
options={viewlet.options}
|
||||
query={resultQuery}
|
||||
showNotification
|
||||
/>
|
||||
{/if}
|
||||
{#if loading || !viewlet || !viewlet?.$lookup?.descriptor?.component}
|
||||
<Loading />
|
||||
{:else}
|
||||
<Component
|
||||
is={viewlet.$lookup.descriptor.component}
|
||||
props={{
|
||||
_class,
|
||||
options: viewlet.options,
|
||||
config: preference?.config ?? viewlet.config,
|
||||
viewlet,
|
||||
viewOptions,
|
||||
viewOptionsConfig,
|
||||
query: resultQuery
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
@ -56,7 +56,7 @@
|
||||
export let space: Ref<Project> | undefined = undefined
|
||||
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
|
||||
export let query: DocumentQuery<Task> = {}
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
|
||||
export let viewOptions: ViewOptions
|
||||
export let viewlet: Viewlet
|
||||
export let config: (string | BuildModelKey)[]
|
||||
|
@ -12,6 +12,9 @@
|
||||
export let baseClass: Ref<Class<Doc>> | undefined = undefined
|
||||
export let kind: ButtonKind = 'regular'
|
||||
export let size: ButtonSize = 'medium'
|
||||
export let justify: 'left' | 'center' = 'center'
|
||||
export let width: string | undefined = undefined
|
||||
export let showAlways: boolean = false
|
||||
export let allTypes = false
|
||||
|
||||
const client = getClient()
|
||||
@ -46,12 +49,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if projectType !== undefined && items.length > 1}
|
||||
{#if projectType !== undefined && (items.length > 1 || showAlways)}
|
||||
<DropdownLabels
|
||||
{focusIndex}
|
||||
{kind}
|
||||
{size}
|
||||
{items}
|
||||
{justify}
|
||||
{width}
|
||||
dataId={'btnSelectTaskType'}
|
||||
bind:selected={value}
|
||||
enableSearch={false}
|
||||
|
@ -16,10 +16,10 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { IconAdd } from '@hcengineering/ui'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '../../node-view'
|
||||
import { findTable, insertColumn, insertRow } from './utils'
|
||||
import { TableMap } from '@tiptap/pm/tables'
|
||||
import { TableMap, updateColumnsOnResize } from '@tiptap/pm/tables'
|
||||
import TableToolbar from './TableToolbar.svelte'
|
||||
|
||||
export let node: NodeViewProps['node']
|
||||
@ -66,58 +66,77 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateColumns (): void {
|
||||
updateColumnsOnResize(node, colgroupElement, tableElement, 25)
|
||||
}
|
||||
|
||||
$: if (node && colgroupElement && tableElement) {
|
||||
updateColumns()
|
||||
}
|
||||
|
||||
let tableElement: HTMLTableElement
|
||||
let colgroupElement: HTMLTableColElement
|
||||
|
||||
onMount(() => {
|
||||
updateColumns()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
editor.off('selectionUpdate', handleSelectionUpdate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<NodeViewWrapper class="table-node-wrapper" data-drag-handle>
|
||||
<div class="table-wrapper" class:table-selected={editable && focused}>
|
||||
<table class={className}>
|
||||
<NodeViewContent as="tbody" />
|
||||
</table>
|
||||
|
||||
{#if editable && focused}
|
||||
<div class="table-toolbar-container" contenteditable="false">
|
||||
<TableToolbar {editor} />
|
||||
</div>
|
||||
|
||||
<!-- add col button -->
|
||||
<div class="table-button-container table-button-container__col flex" contenteditable="false">
|
||||
<div class="w-full h-full flex showOnHover">
|
||||
<button class="table-button w-full h-full" on:click={handleAddColumn}>
|
||||
<div class="table-button__dot" />
|
||||
<div class="table-button__icon"><IconAdd size={'small'} /></div>
|
||||
</button>
|
||||
<div class="table-scroller">
|
||||
<table class={className} bind:this={tableElement}>
|
||||
<colgroup bind:this={colgroupElement} />
|
||||
<NodeViewContent as="tbody" />
|
||||
</table><!-- this comment is necessary to remove the whitespace character that Svelte adds between elements, which causes various problems in prosemirror
|
||||
--></div><!-- https://github.com/sveltejs/svelte/issues/12765
|
||||
--><div class="table-toolbar-components" contenteditable="false">
|
||||
{#if editable && focused}
|
||||
<div class="table-toolbar-container">
|
||||
<TableToolbar {editor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- add row button -->
|
||||
<div class="table-button-container table-button-container__row flex" contenteditable="false">
|
||||
<div class="w-full h-full flex showOnHover">
|
||||
<button class="table-button w-full h-full" on:click={handleAddRow}>
|
||||
<div class="table-button__dot" />
|
||||
<div class="table-button__icon"><IconAdd size={'small'} /></div>
|
||||
</button>
|
||||
<!-- add col button -->
|
||||
<div class="table-button-container table-button-container__col flex">
|
||||
<div class="w-full h-full flex showOnHover">
|
||||
<button class="table-button w-full h-full" on:click={handleAddColumn}>
|
||||
<div class="table-button__dot" />
|
||||
<div class="table-button__icon"><IconAdd size={'small'} /></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- add row button -->
|
||||
<div class="table-button-container table-button-container__row flex">
|
||||
<div class="w-full h-full flex showOnHover">
|
||||
<button class="table-button w-full h-full" on:click={handleAddRow}>
|
||||
<div class="table-button__dot" />
|
||||
<div class="table-button__icon"><IconAdd size={'small'} /></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
.table-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 1.25rem 0;
|
||||
--table-offscreen-spacing: 2rem;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: max-content;
|
||||
max-width: calc(100% + var(--table-offscreen-spacing) * 2);
|
||||
position: relative;
|
||||
|
||||
margin: 0 calc(var(--table-offscreen-spacing) * -1);
|
||||
|
||||
.table-scroller {
|
||||
padding: 1.25rem var(--table-offscreen-spacing);
|
||||
overflow-x: scroll;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
.table-button-container {
|
||||
@ -156,26 +175,22 @@
|
||||
}
|
||||
|
||||
&__col {
|
||||
right: -1.25rem;
|
||||
right: -1.5rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: 1.25rem 0;
|
||||
|
||||
.table-button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
width: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
bottom: -0.25rem;
|
||||
left: var(--table-offscreen-spacing);
|
||||
right: 0;
|
||||
|
||||
.table-button {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
@ -77,7 +77,7 @@ export const tableKitExtensions: KitExtension[] = [
|
||||
[
|
||||
10,
|
||||
Table.configure({
|
||||
resizable: false,
|
||||
resizable: true,
|
||||
HTMLAttributes: {
|
||||
class: 'proseTable'
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ export class HocuspocusCollabProvider extends HocuspocusProvider implements Prov
|
||||
const parameters: Record<string, any> = {}
|
||||
|
||||
const content = configuration.parameters?.content
|
||||
if (content !== null && content !== '') {
|
||||
if (content !== null && content !== undefined && content !== '') {
|
||||
parameters.content = content
|
||||
}
|
||||
|
||||
|
@ -85,7 +85,7 @@
|
||||
export let space: Ref<Project> | undefined = undefined
|
||||
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
|
||||
export let query: DocumentQuery<Issue> = {}
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
|
||||
export let viewOptions: ViewOptions
|
||||
export let viewlet: Viewlet
|
||||
export let config: (string | BuildModelKey)[]
|
||||
|
@ -15,8 +15,10 @@
|
||||
<script lang="ts">
|
||||
import { Person } from '@hcengineering/contact'
|
||||
import { Data, Doc, Ref, generateId } from '@hcengineering/core'
|
||||
import { Card, KeyedAttribute, SpaceSelector, getClient } from '@hcengineering/presentation'
|
||||
import tags, { TagElement } from '@hcengineering/tags'
|
||||
import { Card, KeyedAttribute, SpaceSelector, createQuery, getClient } from '@hcengineering/presentation'
|
||||
import tags, { TagElement, TagReference } from '@hcengineering/tags'
|
||||
import { TaskType } from '@hcengineering/task'
|
||||
import { TaskKindSelector } from '@hcengineering/task-resources'
|
||||
import { StyledTextBox } from '@hcengineering/text-editor-resources'
|
||||
import { Component as ComponentType, IssuePriority, IssueTemplate, Milestone, Project } from '@hcengineering/tracker'
|
||||
import { Component, EditBox, Label } from '@hcengineering/ui'
|
||||
@ -39,6 +41,7 @@
|
||||
export let relatedTo: Doc | undefined
|
||||
|
||||
let labels: TagElement[] = []
|
||||
let kind: Ref<TaskType> | undefined = undefined
|
||||
|
||||
let objectId: Ref<IssueTemplate> = generateId()
|
||||
let object: Data<IssueTemplate> = {
|
||||
@ -93,6 +96,7 @@
|
||||
comments: 0,
|
||||
attachments: 0,
|
||||
labels: labels.map((it) => it._id),
|
||||
kind,
|
||||
relations: relatedTo !== undefined ? [{ _id: relatedTo._id, _class: relatedTo._class }] : []
|
||||
}
|
||||
|
||||
@ -119,6 +123,19 @@
|
||||
function addTagRef (tag: TagElement): void {
|
||||
labels = [...labels, tag]
|
||||
}
|
||||
|
||||
let currentProject: Project | undefined
|
||||
const spaceQuery = createQuery()
|
||||
$: if (_space !== undefined) {
|
||||
spaceQuery.query(tracker.class.Project, { _id: _space }, (res) => {
|
||||
currentProject = res[0]
|
||||
})
|
||||
} else {
|
||||
spaceQuery.unsubscribe()
|
||||
currentProject = undefined
|
||||
}
|
||||
|
||||
$: labelRefs = labels.map((it) => ({ ...(it as unknown as TagReference), _id: generateId(), tag: it._id }))
|
||||
</script>
|
||||
|
||||
<Card
|
||||
@ -144,7 +161,17 @@
|
||||
/>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title" let:label>
|
||||
<Label {label} />
|
||||
<div class="flex-row-center gap-2 pt-1 pb-1 pr-1">
|
||||
<span class="overflow-label">
|
||||
<Label {label} />
|
||||
</span>
|
||||
<TaskKindSelector
|
||||
projectType={currentProject?.type}
|
||||
bind:value={kind}
|
||||
baseClass={tracker.class.Issue}
|
||||
size={'small'}
|
||||
/>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<EditBox
|
||||
@ -187,7 +214,7 @@
|
||||
<Component
|
||||
is={tags.component.TagsDropdownEditor}
|
||||
props={{
|
||||
items: labels,
|
||||
items: labelRefs,
|
||||
key,
|
||||
targetClass: tracker.class.Issue,
|
||||
countLabel: tracker.string.NumberLabels,
|
||||
|
@ -44,9 +44,11 @@
|
||||
const client = getClient()
|
||||
|
||||
let newIssue: IssueTemplateChild = childIssue !== undefined ? { ...childIssue } : getIssueDefaults()
|
||||
let thisRef: HTMLDivElement
|
||||
let thisRef: HTMLDivElement | undefined
|
||||
let focusIssueTitle: () => void
|
||||
let labels: TagElement[] = []
|
||||
let canSave = getTitle(newIssue.title ?? '').length > 0
|
||||
$: canSave = getTitle(newIssue.title ?? '').length > 0
|
||||
|
||||
const labelsQuery = createQuery()
|
||||
|
||||
@ -72,20 +74,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults () {
|
||||
function resetToDefaults (): void {
|
||||
newIssue = getIssueDefaults()
|
||||
labels = []
|
||||
focusIssueTitle?.()
|
||||
}
|
||||
|
||||
function getTitle (value: string) {
|
||||
function getTitle (value: string): string {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function close () {
|
||||
function close (): void {
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
async function createIssue () {
|
||||
function onDelete (): void {
|
||||
dispatch('close', ['delete', newIssue])
|
||||
}
|
||||
|
||||
function createIssue (): void {
|
||||
if (!canSave) {
|
||||
return
|
||||
}
|
||||
@ -99,7 +106,7 @@
|
||||
if (childIssue === undefined) {
|
||||
dispatch('create', value)
|
||||
} else {
|
||||
dispatch('close', value)
|
||||
dispatch('close', ['update', value])
|
||||
}
|
||||
|
||||
resetToDefaults()
|
||||
@ -121,8 +128,7 @@
|
||||
)
|
||||
let currentProject: Project | undefined = undefined
|
||||
|
||||
$: thisRef && thisRef.scrollIntoView({ behavior: 'smooth' })
|
||||
$: canSave = getTitle(newIssue.title ?? '').length > 0
|
||||
$: thisRef !== undefined && thisRef.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
$: labelRefs = labels.map((it) => ({ ...(it as unknown as TagReference), _id: generateId(), tag: it._id }))
|
||||
</script>
|
||||
@ -200,6 +206,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-2 buttons-group small-gap">
|
||||
{#if childIssue !== undefined}
|
||||
<Button label={presentation.string.Delete} size="small" kind="dangerous" on:click={onDelete} />
|
||||
{/if}
|
||||
<Button label={presentation.string.Cancel} size="small" kind="ghost" on:click={close} />
|
||||
<Button
|
||||
disabled={!canSave}
|
||||
|
@ -37,7 +37,7 @@
|
||||
let dragIndex: number | null = null
|
||||
let hoveringIndex: number | null = null
|
||||
|
||||
function openIssue (evt: MouseEvent, target: IssueTemplateChild) {
|
||||
function openIssue (evt: MouseEvent, target: IssueTemplateChild): void {
|
||||
showPopup(
|
||||
IssueTemplateChildEditor,
|
||||
{
|
||||
@ -48,26 +48,32 @@
|
||||
childIssue: target
|
||||
},
|
||||
eventToHTMLElement(evt),
|
||||
(evt: IssueTemplateChild | undefined | null) => {
|
||||
(evt: ['update' | 'delete', IssueTemplateChild] | undefined | null) => {
|
||||
if (evt != null) {
|
||||
const pos = issues.findIndex((it) => it.id === evt.id)
|
||||
const pos = issues.findIndex((it) => it.id === target.id)
|
||||
if (pos !== -1) {
|
||||
issues[pos] = evt
|
||||
dispatch('update-issue', evt)
|
||||
if (evt[0] === 'delete') {
|
||||
issues.splice(pos, 1)
|
||||
issues = issues
|
||||
dispatch('update-issues', issues)
|
||||
} else {
|
||||
issues[pos] = evt[1]
|
||||
dispatch('update-issue', evt[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function resetDrag () {
|
||||
function resetDrag (): void {
|
||||
dragId = null
|
||||
dragIndex = null
|
||||
hoveringIndex = null
|
||||
}
|
||||
|
||||
function handleDragStart (ev: DragEvent, index: number, item: IssueTemplateChild) {
|
||||
if (ev.dataTransfer) {
|
||||
function handleDragStart (ev: DragEvent, index: number, item: IssueTemplateChild): void {
|
||||
if (ev.dataTransfer != null) {
|
||||
ev.dataTransfer.effectAllowed = 'move'
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
dragIndex = index
|
||||
@ -75,8 +81,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop (ev: DragEvent, toIndex: number) {
|
||||
if (ev.dataTransfer && dragIndex !== null && toIndex !== dragIndex) {
|
||||
function handleDrop (ev: DragEvent, toIndex: number): void {
|
||||
if (ev.dataTransfer != null && dragIndex !== null && toIndex !== dragIndex) {
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
|
||||
dispatch('move', { id: dragId, toIndex })
|
||||
@ -98,7 +104,7 @@
|
||||
let currentProject: Project | undefined = undefined
|
||||
|
||||
function getIssueTemplateId (currentProject: Project | undefined, issue: IssueTemplateChild): string {
|
||||
return currentProject
|
||||
return currentProject !== undefined
|
||||
? `${currentProject.identifier}-${issues.findIndex((it) => it.id === issue.id)}`
|
||||
: `${issues.findIndex((it) => it.id === issue.id)}}`
|
||||
}
|
||||
@ -175,6 +181,10 @@
|
||||
kind={'link'}
|
||||
bind:value={issue.kind}
|
||||
baseClass={tracker.class.Issue}
|
||||
on:change={(evt) => {
|
||||
dispatch('update-issue', { id: issue.id, kind: evt.detail })
|
||||
issue.kind = evt.detail
|
||||
}}
|
||||
/>
|
||||
<EstimationEditor
|
||||
kind={'link'}
|
||||
|
@ -35,17 +35,15 @@
|
||||
let isCollapsed = false
|
||||
let isCreating = false
|
||||
|
||||
function handleIssueSwap (ev: CustomEvent<{ id: Ref<Issue>, toIndex: number }>) {
|
||||
if (children) {
|
||||
const { id, toIndex } = ev.detail
|
||||
const index = children.findIndex((p) => p.id === id)
|
||||
if (index !== -1 && index !== toIndex) {
|
||||
const [fromIssue] = children.splice(index, 1)
|
||||
const leftPart = children.slice(0, toIndex)
|
||||
const rightPart = children.slice(toIndex)
|
||||
children = [...leftPart, fromIssue, ...rightPart]
|
||||
dispatch('update-issues', children)
|
||||
}
|
||||
function handleIssueSwap (ev: CustomEvent<{ id: Ref<Issue>, toIndex: number }>): void {
|
||||
const { id, toIndex } = ev.detail
|
||||
const index = children.findIndex((p) => p.id === id)
|
||||
if (index !== -1 && index !== toIndex) {
|
||||
const [fromIssue] = children.splice(index, 1)
|
||||
const leftPart = children.slice(0, toIndex)
|
||||
const rightPart = children.slice(toIndex)
|
||||
children = [...leftPart, fromIssue, ...rightPart]
|
||||
dispatch('update-issues', children)
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,6 +94,7 @@
|
||||
{project}
|
||||
on:move={handleIssueSwap}
|
||||
on:update-issue
|
||||
on:update-issues
|
||||
/>
|
||||
</Scroller>
|
||||
</div>
|
||||
|
@ -14,8 +14,10 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { generateId, Ref, WithLookup } from '@hcengineering/core'
|
||||
import { AttributeBarEditor, getClient, KeyedAttribute } from '@hcengineering/presentation'
|
||||
import { AttributeBarEditor, createQuery, getClient, KeyedAttribute } from '@hcengineering/presentation'
|
||||
import tags, { TagElement, TagReference } from '@hcengineering/tags'
|
||||
import task, { Project } from '@hcengineering/task'
|
||||
import { TaskKindSelector } from '@hcengineering/task-resources'
|
||||
import type { IssueTemplate } from '@hcengineering/tracker'
|
||||
import { Component, Label } from '@hcengineering/ui'
|
||||
import { getFiltredKeys, isCollectionAttr } from '@hcengineering/view-resources'
|
||||
@ -37,7 +39,7 @@
|
||||
keys = filtredKeys.filter((key) => !isCollectionAttr(hierarchy, key))
|
||||
}
|
||||
|
||||
$: updateKeys(['title', 'description', 'priority', 'number', 'assignee', 'component', 'milestone'])
|
||||
$: updateKeys(['title', 'description', 'priority', 'number', 'assignee', 'component', 'milestone', 'kind'])
|
||||
|
||||
const key: KeyedAttribute = {
|
||||
key: 'labels',
|
||||
@ -62,13 +64,37 @@
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let currentProject: Project | undefined
|
||||
const spaceQuery = createQuery()
|
||||
spaceQuery.query(tracker.class.Project, { _id: issue.space }, (res) => {
|
||||
currentProject = res[0]
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="popupPanel-body__aside-grid">
|
||||
<span class="labelOnPanel">
|
||||
<Label label={task.string.TaskType} />
|
||||
</span>
|
||||
<TaskKindSelector
|
||||
projectType={currentProject?.type}
|
||||
value={issue.kind}
|
||||
baseClass={tracker.class.Issue}
|
||||
justify={'left'}
|
||||
width={'100%'}
|
||||
size={'medium'}
|
||||
kind={'link'}
|
||||
showAlways
|
||||
on:change={async (evt) => {
|
||||
if (evt.detail !== undefined) {
|
||||
await client.update(issue, { kind: evt.detail })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span class="labelOnPanel">
|
||||
<Label label={tracker.string.Priority} />
|
||||
</span>
|
||||
<PriorityEditor value={issue} size={'medium'} shouldShowLabel />
|
||||
<PriorityEditor value={issue} size={'medium'} justify={'left'} width={'100%'} shouldShowLabel />
|
||||
|
||||
<span class="labelOnPanel">
|
||||
<Label label={tracker.string.Assignee} />
|
||||
|
@ -29,6 +29,7 @@
|
||||
export let showLabel: IntlString | undefined = undefined
|
||||
export let draft = false
|
||||
export let showHeader: boolean = true
|
||||
export let isMainClass: boolean = false
|
||||
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
@ -53,50 +54,50 @@
|
||||
$: collapsed = getCollapsed(_class, nonEmpty)
|
||||
</script>
|
||||
|
||||
{#if keys.length}
|
||||
{#if showHeader}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="attrbar-header"
|
||||
class:collapsed
|
||||
on:click={() => {
|
||||
collapsed = !collapsed
|
||||
}}
|
||||
>
|
||||
<div class="flex-row-center">
|
||||
<span class="overflow-label">
|
||||
<Label {label} />
|
||||
</span>
|
||||
<div class="icon-arrow">
|
||||
<svg fill="var(--theme-dark-color)" viewBox="0 0 6 6" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0,0L6,3L0,6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool">
|
||||
{#if !$restrictionStore.disableNavigation}
|
||||
<Button
|
||||
icon={setting.icon.Setting}
|
||||
kind={'link'}
|
||||
size={'medium'}
|
||||
showTooltip={{ label: setting.string.ClassSetting }}
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const loc = getCurrentResolvedLocation()
|
||||
loc.path[2] = settingId
|
||||
loc.path[3] = 'setting'
|
||||
loc.path[4] = 'classes'
|
||||
loc.path.length = 5
|
||||
loc.query = { _class }
|
||||
loc.fragment = undefined
|
||||
navigate(loc)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if showHeader && (keys.length > 0 || isMainClass)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="attrbar-header"
|
||||
class:collapsed
|
||||
on:click={() => {
|
||||
collapsed = !collapsed
|
||||
}}
|
||||
>
|
||||
<div class="flex-row-center">
|
||||
<span class="overflow-label">
|
||||
<Label {label} />
|
||||
</span>
|
||||
<div class="icon-arrow">
|
||||
<svg fill="var(--theme-dark-color)" viewBox="0 0 6 6" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0,0L6,3L0,6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="tool">
|
||||
{#if !$restrictionStore.disableNavigation}
|
||||
<Button
|
||||
icon={setting.icon.Setting}
|
||||
kind={'link'}
|
||||
size={'medium'}
|
||||
showTooltip={{ label: setting.string.ClassSetting }}
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const loc = getCurrentResolvedLocation()
|
||||
loc.path[2] = settingId
|
||||
loc.path[3] = 'setting'
|
||||
loc.path[4] = 'classes'
|
||||
loc.path.length = 5
|
||||
loc.query = { _class }
|
||||
loc.fragment = undefined
|
||||
navigate(loc)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if keys.length}
|
||||
<div class="collapsed-container" class:collapsed>
|
||||
<AttributesBar {_class} {object} {keys} {readonly} {draft} on:update />
|
||||
</div>
|
||||
|
@ -38,6 +38,7 @@
|
||||
{allowedCollections}
|
||||
{showHeader}
|
||||
{readonly}
|
||||
isMainClass
|
||||
on:update
|
||||
/>
|
||||
{#each mixins as mixin}
|
||||
|
@ -16,7 +16,7 @@
|
||||
import type { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
|
||||
import { ActionContext } from '@hcengineering/presentation'
|
||||
import { FadeOptions, Scroller, tableSP } from '@hcengineering/ui'
|
||||
import { BuildModelKey, ViewOptions, Viewlet } from '@hcengineering/view'
|
||||
import { BuildModelKey, ViewOptionModel, ViewOptions, Viewlet } from '@hcengineering/view'
|
||||
import { onMount } from 'svelte'
|
||||
import { focusStore, ListSelectionProvider, SelectDirection } from '../selection'
|
||||
import { LoadingProps } from '../utils'
|
||||
@ -35,6 +35,7 @@
|
||||
export let fade: FadeOptions = tableSP
|
||||
export let prefferedSorting: string = 'modifiedOn'
|
||||
export let viewOptions: ViewOptions | undefined = undefined
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
|
||||
export let viewlet: Viewlet | undefined = undefined
|
||||
export let readonly = false
|
||||
|
||||
@ -83,7 +84,7 @@
|
||||
{prefferedSorting}
|
||||
{tableId}
|
||||
{viewOptions}
|
||||
viewOptionsConfig={viewlet?.viewOptions?.other}
|
||||
viewOptionsConfig={viewOptionsConfig ?? viewlet?.viewOptions?.other}
|
||||
selection={listProvider.current($focusStore)}
|
||||
{readonly}
|
||||
on:row-focus={(evt) => {
|
||||
|
@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { ButtonIcon, showPopup, closeTooltip, IconOptions } from '@hcengineering/ui'
|
||||
import { ViewOptions, ViewOptionsModel, Viewlet } from '@hcengineering/view'
|
||||
import { ViewOptionModel, ViewOptions, ViewOptionsModel, Viewlet } from '@hcengineering/view'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import view from '../plugin'
|
||||
import { focusStore } from '../selection'
|
||||
@ -27,6 +27,7 @@
|
||||
export let kind: 'primary' | 'secondary' | 'tertiary' | 'negative' = 'secondary'
|
||||
export let viewOptions: ViewOptions
|
||||
export let disabled: boolean = false
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const client = getClient()
|
||||
@ -89,6 +90,9 @@
|
||||
mergedModel.orderBy = mergedModel.orderBy.filter((it, idx, arr) => arr.findIndex((q) => it[0] === q[0]) === idx)
|
||||
mergedModel.other = mergedModel.other.filter((it, idx, arr) => arr.findIndex((q) => q.key === it.key) === idx)
|
||||
|
||||
if (viewOptionsConfig !== undefined) {
|
||||
mergedModel.other = viewOptionsConfig
|
||||
}
|
||||
showPopup(
|
||||
ViewOptionsEditor,
|
||||
{ viewlet, config: mergedModel, viewOptions: getClient().getHierarchy().clone(viewOptions) },
|
||||
|
@ -14,9 +14,9 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ButtonIcon, showPopup, closeTooltip } from '@hcengineering/ui'
|
||||
import { ViewOptions, Viewlet } from '@hcengineering/view'
|
||||
import { ViewOptionModel, ViewOptions, Viewlet } from '@hcengineering/view'
|
||||
import view from '../plugin'
|
||||
import { getViewOptions, viewOptionStore } from '../viewOptions'
|
||||
import { getViewOptions, viewOptionStore, defaultOptions } from '../viewOptions'
|
||||
import ViewOptionsButton from './ViewOptionsButton.svelte'
|
||||
import ViewletSetting from './ViewletSetting.svelte'
|
||||
import { restrictionStore } from '../utils'
|
||||
@ -25,6 +25,7 @@
|
||||
export let viewOptions: ViewOptions | undefined = undefined
|
||||
export let viewlet: Viewlet | undefined = undefined
|
||||
export let disabled: boolean = false
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
|
||||
|
||||
let btn: HTMLButtonElement
|
||||
let pressed: boolean = false
|
||||
@ -37,14 +38,14 @@
|
||||
})
|
||||
}
|
||||
|
||||
$: viewOptions = getViewOptions(viewlet, $viewOptionStore)
|
||||
$: viewOptions = getViewOptions(viewlet, $viewOptionStore, defaultOptions)
|
||||
|
||||
$: disabled = $restrictionStore.readonly
|
||||
</script>
|
||||
|
||||
{#if viewlet}
|
||||
{#if viewOptions}
|
||||
<ViewOptionsButton {viewlet} {kind} {viewOptions} />
|
||||
<ViewOptionsButton {viewlet} {kind} {viewOptions} {viewOptionsConfig} />
|
||||
{/if}
|
||||
<ButtonIcon
|
||||
icon={view.icon.Configure}
|
||||
|
@ -72,7 +72,7 @@
|
||||
export let level: number
|
||||
export let initIndex = 0
|
||||
export let newObjectProps: (doc: Doc | undefined) => Record<string, any> | undefined
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
|
||||
export let dragItem: {
|
||||
doc?: Doc
|
||||
revert?: () => void
|
||||
|
@ -67,7 +67,7 @@
|
||||
export let configurationsVersion: number
|
||||
export let viewOptions: ViewOptions
|
||||
export let newObjectProps: (doc: Doc | undefined) => Record<string, any> | undefined
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
|
||||
export let dragItem: {
|
||||
doc?: Doc
|
||||
revert?: () => void
|
||||
|
@ -17,7 +17,7 @@
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
import { ActionContext } from '@hcengineering/presentation'
|
||||
import { AnyComponent, Scroller, resizeObserver } from '@hcengineering/ui'
|
||||
import { BuildModelKey, ViewOptions, Viewlet } from '@hcengineering/view'
|
||||
import { BuildModelKey, ViewOptionModel, ViewOptions, Viewlet } from '@hcengineering/view'
|
||||
import { onMount } from 'svelte'
|
||||
import { ListSelectionProvider, SelectDirection, focusStore } from '../..'
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
export let createItemLabel: IntlString | undefined
|
||||
export let createItemEvent: string | undefined
|
||||
export let viewOptions: ViewOptions
|
||||
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
|
||||
export let props: Record<string, any> = {}
|
||||
|
||||
let list: List
|
||||
@ -94,7 +95,7 @@
|
||||
{props}
|
||||
{listProvider}
|
||||
compactMode={listWidth <= 800}
|
||||
viewOptionsConfig={viewlet.viewOptions?.other}
|
||||
viewOptionsConfig={viewOptionsConfig ?? viewlet.viewOptions?.other}
|
||||
selectedObjectIds={$selection ?? []}
|
||||
selection={listProvider.current($focusStore)}
|
||||
on:row-focus={(event) => {
|
||||
|
@ -18,7 +18,7 @@ import { groupByCategory } from './utils'
|
||||
|
||||
export const noCategory = '#no_category'
|
||||
|
||||
export const defaulOptions: ViewOptions = {
|
||||
export const defaultOptions: ViewOptions = {
|
||||
groupBy: [noCategory],
|
||||
orderBy: ['modifiedBy', SortingOrder.Descending]
|
||||
}
|
||||
@ -75,8 +75,8 @@ function _getViewOptions (viewlet: Viewlet, viewOptionStore: Map<string, ViewOpt
|
||||
|
||||
function getDefaults (viewOptions: ViewOptionsModel): ViewOptions {
|
||||
const res: ViewOptions = {
|
||||
groupBy: [viewOptions.groupBy[0] ?? defaulOptions.groupBy[0]],
|
||||
orderBy: viewOptions.orderBy?.[0] ?? defaulOptions.orderBy
|
||||
groupBy: [viewOptions.groupBy[0] ?? defaultOptions.groupBy[0]],
|
||||
orderBy: viewOptions.orderBy?.[0] ?? defaultOptions.orderBy
|
||||
}
|
||||
for (const opt of viewOptions.other) {
|
||||
res[opt.key] = opt.defaultValue
|
||||
@ -87,7 +87,7 @@ function getDefaults (viewOptions: ViewOptionsModel): ViewOptions {
|
||||
export function getViewOptions (
|
||||
viewlet: Viewlet | undefined,
|
||||
viewOptionStore: Map<string, ViewOptions>,
|
||||
defaults = defaulOptions
|
||||
defaults = defaultOptions
|
||||
): ViewOptions {
|
||||
if (viewlet === undefined) {
|
||||
return { ...defaults }
|
||||
|
@ -126,7 +126,8 @@
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
const HIDE_NAVIGATOR = 720
|
||||
const HIDE_ASIDE = 1024
|
||||
const HIDE_ASIDE = 680 // sm
|
||||
const FLOAT_ASIDE = 1024 // lg
|
||||
let contentPanel: HTMLElement
|
||||
|
||||
const { setTheme } = getContext<{ setTheme: (theme: string) => void }>('theme')
|
||||
@ -642,13 +643,14 @@
|
||||
}
|
||||
}
|
||||
checkWorkbenchWidth()
|
||||
$: if ($deviceInfo.docWidth <= HIDE_ASIDE && !$deviceInfo.aside.float) {
|
||||
$: if ($deviceInfo.docWidth <= FLOAT_ASIDE && !$deviceInfo.aside.float) {
|
||||
$deviceInfo.aside.visible = false
|
||||
$deviceInfo.aside.float = true
|
||||
} else if ($deviceInfo.docWidth > HIDE_ASIDE && $deviceInfo.aside.float) {
|
||||
} else if ($deviceInfo.docWidth > FLOAT_ASIDE && $deviceInfo.aside.float) {
|
||||
$deviceInfo.aside.float = false
|
||||
$deviceInfo.aside.visible = !hiddenAside
|
||||
}
|
||||
$: expandedFloatASide = $deviceInfo.docWidth <= FLOAT_ASIDE && $deviceInfo.docWidth > HIDE_ASIDE
|
||||
const checkOnHide = (): void => {
|
||||
if ($deviceInfo.navigator.visible && $deviceInfo.navigator.float) $deviceInfo.navigator.visible = false
|
||||
}
|
||||
@ -976,6 +978,7 @@
|
||||
<div
|
||||
bind:this={contentPanel}
|
||||
class={navigatorModel === undefined ? 'hulyPanels-container' : 'hulyComponent overflow-hidden'}
|
||||
class:straighteningCorners={expandedFloatASide && $sidebarStore.variant === SidebarVariant.EXPANDED}
|
||||
data-id={'contentPanel'}
|
||||
>
|
||||
{#if currentApplication && currentApplication.component}
|
||||
@ -1014,15 +1017,15 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if !$deviceInfo.aside.float}
|
||||
{#if $sidebarStore.variant === SidebarVariant.EXPANDED}
|
||||
{#if $deviceInfo.docWidth > HIDE_ASIDE}
|
||||
{#if $sidebarStore.variant === SidebarVariant.EXPANDED && !expandedFloatASide}
|
||||
<Separator name={'main'} index={0} color={'transparent'} separatorSize={0} short />
|
||||
{/if}
|
||||
<WidgetsBar />
|
||||
<WidgetsBar expandedFloat={expandedFloatASide} />
|
||||
{/if}
|
||||
</div>
|
||||
<Dock />
|
||||
{#if $deviceInfo.aside.float}
|
||||
{#if $deviceInfo.docWidth <= HIDE_ASIDE}
|
||||
<div
|
||||
class="antiPanel-navigator right fly no-print {$deviceInfo.navigator.direction === 'horizontal'
|
||||
? 'portrait'
|
||||
@ -1067,6 +1070,9 @@
|
||||
&.inner {
|
||||
background-color: var(--theme-navpanel-color);
|
||||
|
||||
.straighteningCorners {
|
||||
border-radius: var(--medium-BorderRadius) 0 0 var(--medium-BorderRadius);
|
||||
}
|
||||
&.rounded {
|
||||
border-radius: 0 var(--medium-BorderRadius) var(--medium-BorderRadius) 0;
|
||||
}
|
||||
|
@ -24,6 +24,8 @@
|
||||
import SidebarMini from './SidebarMini.svelte'
|
||||
import SidebarExpanded from './SidebarExpanded.svelte'
|
||||
|
||||
export let expandedFloat: boolean = false
|
||||
|
||||
const client = getClient()
|
||||
|
||||
const widgets = client.getModel().findAllSync(workbench.class.Widget, {})
|
||||
@ -64,13 +66,14 @@
|
||||
<div
|
||||
id="sidebar"
|
||||
class="antiPanel-application vertical sidebar-container"
|
||||
class:mini
|
||||
class:float={$deviceInfo.aside.float}
|
||||
class:mini={mini || expandedFloat}
|
||||
class:expandedFloat
|
||||
class:float={$deviceInfo.aside.float && !expandedFloat}
|
||||
>
|
||||
{#if mini}
|
||||
<SidebarMini {widgets} {preferences} />
|
||||
{:else if $sidebarStore.variant === SidebarVariant.EXPANDED}
|
||||
<SidebarExpanded {widgets} {preferences} />
|
||||
<SidebarExpanded {widgets} {preferences} float={expandedFloat} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@ -96,8 +99,18 @@
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar-container {
|
||||
width: 100%;
|
||||
|
||||
&:not(.expandedFloat) {
|
||||
border-radius: var(--medium-BorderRadius);
|
||||
}
|
||||
&.expandedFloat {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.sidebar-container {
|
||||
border: 1px solid var(--theme-navpanel-divider);
|
||||
border-radius: var(--medium-BorderRadius);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -22,7 +22,8 @@
|
||||
Location,
|
||||
Header,
|
||||
Breadcrumbs,
|
||||
getCurrentLocation
|
||||
getCurrentLocation,
|
||||
Separator
|
||||
} from '@hcengineering/ui'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
@ -32,6 +33,7 @@
|
||||
|
||||
export let widgets: Widget[] = []
|
||||
export let preferences: WidgetPreference[] = []
|
||||
export let float: boolean = false
|
||||
|
||||
let widgetId: Ref<Widget> | undefined = undefined
|
||||
let widget: Widget | undefined = undefined
|
||||
@ -96,70 +98,92 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sidebar-content">
|
||||
{#if widget?.component}
|
||||
<div class="component" use:resizeObserver={resize}>
|
||||
{#if widget.headerLabel}
|
||||
<Header
|
||||
allowFullsize={false}
|
||||
type="type-aside"
|
||||
hideBefore={true}
|
||||
hideActions={false}
|
||||
hideDescription={true}
|
||||
adaptive="disabled"
|
||||
closeOnEscape={false}
|
||||
<div class="sidebar-wrap__content" class:float>
|
||||
{#if float}
|
||||
<Separator name={'main'} index={0} color={'var(--theme-navpanel-border)'} float={'sidebar'} />
|
||||
{/if}
|
||||
<div class="sidebar-content">
|
||||
{#if widget?.component}
|
||||
<div class="component" use:resizeObserver={resize}>
|
||||
{#if widget.headerLabel}
|
||||
<Header
|
||||
allowFullsize={false}
|
||||
type="type-aside"
|
||||
hideBefore={true}
|
||||
hideActions={false}
|
||||
hideDescription={true}
|
||||
adaptive="disabled"
|
||||
closeOnEscape={false}
|
||||
on:close={() => {
|
||||
if (widget !== undefined) {
|
||||
closeWidget(widget._id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs items={[{ label: widget.headerLabel }]} currentOnly />
|
||||
</Header>
|
||||
{/if}
|
||||
<Component
|
||||
is={widget?.component}
|
||||
props={{ tab, widgetState, height: componentHeight, width: componentWidth, widget }}
|
||||
on:close={() => {
|
||||
if (widget !== undefined) {
|
||||
closeWidget(widget._id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Breadcrumbs items={[{ label: widget.headerLabel }]} currentOnly />
|
||||
</Header>
|
||||
{/if}
|
||||
<Component
|
||||
is={widget?.component}
|
||||
props={{ tab, widgetState, height: componentHeight, width: componentWidth, widget }}
|
||||
on:close={() => {
|
||||
if (widget !== undefined) {
|
||||
closeWidget(widget._id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if widget !== undefined && tabs.length > 0}
|
||||
<SidebarTabs
|
||||
{tabs}
|
||||
selected={tab?.id}
|
||||
{widget}
|
||||
on:close={(e) => {
|
||||
void handleTabClose(e.detail, widget)
|
||||
}}
|
||||
on:open={(e) => {
|
||||
handleTabOpen(e.detail, widget)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if widget !== undefined && tabs.length > 0}
|
||||
<SidebarTabs
|
||||
{tabs}
|
||||
selected={tab?.id}
|
||||
{widget}
|
||||
on:close={(e) => {
|
||||
void handleTabClose(e.detail, widget)
|
||||
}}
|
||||
on:open={(e) => {
|
||||
handleTabOpen(e.detail, widget)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<WidgetsBar {widgets} {preferences} selected={widgetId} />
|
||||
<WidgetsBar {widgets} {preferences} selected={widgetId} expandedFloat={float} />
|
||||
|
||||
<style lang="scss">
|
||||
.sidebar-wrap__content,
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border-top: 1px solid transparent; // var(--theme-divider-color);
|
||||
border-right: 1px solid var(--theme-divider-color);
|
||||
overflow: auto;
|
||||
|
||||
width: calc(100% - 3.5rem);
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-wrap__content {
|
||||
width: calc(100% - 3.5rem);
|
||||
background-color: var(--theme-panel-color);
|
||||
|
||||
border-top: 1px solid transparent; // var(--theme-divider-color);
|
||||
border-right: 1px solid var(--theme-divider-color);
|
||||
border-left: none;
|
||||
|
||||
&.float {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 3.5rem;
|
||||
border-top-color: var(--theme-divider-color);
|
||||
border-bottom: 1px solid var(--theme-divider-color);
|
||||
z-index: 491;
|
||||
filter: drop-shadow(-2px 0 5px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
.sidebar-content {
|
||||
overflow: auto;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.component {
|
||||
|
@ -86,11 +86,11 @@
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
max-width: 30px;
|
||||
width: 2rem;
|
||||
min-width: 2rem;
|
||||
max-width: 2rem;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--theme-divider-color);
|
||||
border-left: 1px solid var(--theme-divider-color);
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0;
|
||||
|
@ -24,6 +24,7 @@
|
||||
export let widgets: Widget[] = []
|
||||
export let preferences: WidgetPreference[] = []
|
||||
export let selected: Ref<Widget> | undefined = undefined
|
||||
export let expandedFloat: boolean = false
|
||||
|
||||
function handleAddWidget (): void {
|
||||
showPopup(AddWidgetsPopup, { widgets })
|
||||
@ -31,7 +32,7 @@
|
||||
|
||||
function handleSelectWidget (widget: Widget): void {
|
||||
if (selected === widget._id) {
|
||||
if ($deviceInfo.aside.float) $deviceInfo.aside.visible = false
|
||||
if ($deviceInfo.aside.float && !expandedFloat) $deviceInfo.aside.visible = false
|
||||
else minimizeSidebar(true)
|
||||
} else {
|
||||
openWidget(widget, $sidebarStore.widgetsState.get(widget._id)?.data, { active: true, openedByUser: true })
|
||||
|
@ -9,6 +9,7 @@
|
||||
"license": "EPL-2.0",
|
||||
"scripts": {
|
||||
"start": "rush bundle --to @hcengineering/pod-server && cross-env NODE_ENV=production MODEL_VERSION=$(node ../../common/scripts/show_version.js) ACCOUNTS_URL=http://localhost:3000 REKONI_URL=http://localhost:4004 MONGO_URL=mongodb://localhost:27017 DB_URL=mongodb://localhost:27017 FRONT_URL=http://localhost:8087 UPLOAD_URL=/upload MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin METRICS_CONSOLE=true SERVER_SECRET=secret OPERATION_PROFILING=false MODEL_JSON=../../models/all/bundle/model.json STATS_URL=http://host.docker.internal:4900 node --inspect bundle/bundle.js",
|
||||
"start-cr": "rush bundle --to @hcengineering/pod-server && cross-env NODE_ENV=production MODEL_VERSION=$(node ../../common/scripts/show_version.js) ACCOUNTS_URL=http://localhost:3000 REKONI_URL=http://localhost:4004 DB_URL=postgresql://root@host.docker.internal:26257/defaultdb?sslmode=disable FRONT_URL=http://localhost:8087 UPLOAD_URL=/upload MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin METRICS_CONSOLE=true SERVER_SECRET=secret OPERATION_PROFILING=false MODEL_JSON=../../models/all/bundle/model.json STATS_URL=http://host.docker.internal:4900 FULLTEXT_URL=http://host.docker.internal:4702 SERVER_PORT=3332 node --inspect bundle/bundle.js",
|
||||
"start-flame": "rush bundle --to @hcengineering/pod-server && cross-env NODE_ENV=production MODEL_VERSION=$(node ../../common/scripts/show_version.js) ACCOUNTS_URL=http://localhost:3000 REKONI_URL=http://localhost:4004 MONGO_URL=mongodb://localhost:27017 FRONT_URL=http://localhost:8087 UPLOAD_URL=/upload MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin METRICS_CONSOLE=true SERVER_SECRET=secret MODEL_JSON=../../models/all/bundle/model.json clinic flame --dest ./out -- node --nolazy -r ts-node/register --enable-source-maps src/__start.ts",
|
||||
"start-raw": "ts-node src/__start.ts",
|
||||
"build": "compile",
|
||||
|
@ -14,22 +14,16 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import {
|
||||
type Branding,
|
||||
type BrandingMap,
|
||||
type MeasureContext,
|
||||
type Tx,
|
||||
type WorkspaceIdWithUrl
|
||||
} from '@hcengineering/core'
|
||||
import { type BrandingMap, type MeasureContext, type Tx } from '@hcengineering/core'
|
||||
import { buildStorageFromConfig } from '@hcengineering/server-storage'
|
||||
|
||||
import { ClientSession, startSessionManager } from '@hcengineering/server'
|
||||
import {
|
||||
type Pipeline,
|
||||
type ServerFactory,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
type StorageConfiguration
|
||||
type StorageConfiguration,
|
||||
type Workspace
|
||||
} from '@hcengineering/server-core'
|
||||
import { type Token } from '@hcengineering/server-token'
|
||||
|
||||
@ -42,9 +36,9 @@ import {
|
||||
registerTxAdapterFactory
|
||||
} from '@hcengineering/server-pipeline'
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createMongoAdapter, createMongoDestroyAdapter, createMongoTxAdapter } from '@hcengineering/mongo'
|
||||
import { createPostgreeDestroyAdapter, createPostgresAdapter, createPostgresTxAdapter } from '@hcengineering/postgres'
|
||||
import { readFileSync } from 'node:fs'
|
||||
const model = JSON.parse(readFileSync(process.env.MODEL_JSON ?? 'model.json').toString()) as Tx[]
|
||||
|
||||
registerStringLoaders()
|
||||
@ -93,13 +87,8 @@ export function start (
|
||||
{ ...opt, externalStorage, adapterSecurity: dbUrl.startsWith('postgresql') },
|
||||
{}
|
||||
)
|
||||
const sessionFactory = (
|
||||
token: Token,
|
||||
pipeline: Pipeline,
|
||||
workspaceId: WorkspaceIdWithUrl,
|
||||
branding: Branding | null
|
||||
): Session => {
|
||||
return new ClientSession(token, pipeline, workspaceId, branding, token.extra?.mode === 'backup')
|
||||
const sessionFactory = (token: Token, workspace: Workspace): Session => {
|
||||
return new ClientSession(token, workspace, token.extra?.mode === 'backup')
|
||||
}
|
||||
|
||||
const { shutdown: onClose, sessionManager } = startSessionManager(metrics, {
|
||||
|
@ -456,6 +456,76 @@ export async function IssueToDoFactory (actualTx: TxCUD<Issue>, control: Trigger
|
||||
return []
|
||||
}
|
||||
|
||||
async function generateChangeStatusTx (
|
||||
control: TriggerControl,
|
||||
factory: TxFactory,
|
||||
issue: Issue,
|
||||
todo: ToDo
|
||||
): Promise<Tx | undefined> {
|
||||
if (await isClassic(control, issue.space)) {
|
||||
const taskType = (await control.modelDb.findAll(task.class.TaskType, { _id: issue.kind }))[0]
|
||||
if (taskType !== undefined) {
|
||||
const index = taskType.statuses.findIndex((p) => p === issue.status)
|
||||
const nextStatus = taskType.statuses[index + 1]
|
||||
const currentStatus = taskType.statuses[index]
|
||||
|
||||
const current = await getStatus(control, currentStatus)
|
||||
const next = await getStatus(control, nextStatus)
|
||||
if (isValidStatusChange(current, next)) {
|
||||
const unfinished = await getUnfinishedTodos(control, issue._id)
|
||||
if (unfinished.length > 0) return
|
||||
|
||||
const testers = await getTesters(control)
|
||||
for (const tester of testers) {
|
||||
if (!(await tester(control, todo))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return createStatusChangeTx(factory, issue, nextStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function isClassic (control: TriggerControl, space: Ref<Project>): Promise<boolean> {
|
||||
const project = (await control.findAll(control.ctx, tracker.class.Project, { _id: space }))[0]
|
||||
const projectType = (await control.modelDb.findAll(task.class.ProjectType, { _id: project.type }))[0]
|
||||
return projectType?.classic ?? false
|
||||
}
|
||||
|
||||
async function getUnfinishedTodos (control: TriggerControl, issueId: Ref<Issue>): Promise<ToDo[]> {
|
||||
return await control.findAll(control.ctx, time.class.ToDo, { attachedTo: issueId, doneOn: null }, { limit: 1 })
|
||||
}
|
||||
|
||||
async function getTesters (
|
||||
control: TriggerControl
|
||||
): Promise<((control: TriggerControl, todo: ToDo) => Promise<boolean>)[]> {
|
||||
const helpers = await control.modelDb.findAll<TodoAutomationHelper>(time.class.TodoAutomationHelper, {})
|
||||
return await Promise.all(helpers.map((it) => getResource(it.onDoneTester)))
|
||||
}
|
||||
|
||||
async function getStatus (control: TriggerControl, statusId: Ref<Status> | undefined): Promise<Status | undefined> {
|
||||
if (statusId === undefined) return
|
||||
return (await control.modelDb.findAll(core.class.Status, { _id: statusId }))[0]
|
||||
}
|
||||
|
||||
function isValidStatusChange (current: Status | undefined, next: Status | undefined): boolean {
|
||||
if (current === undefined || next === undefined) return false
|
||||
return (
|
||||
current.category !== task.statusCategory.Lost &&
|
||||
next.category !== task.statusCategory.Lost &&
|
||||
current.category !== task.statusCategory.Won
|
||||
)
|
||||
}
|
||||
|
||||
function createStatusChangeTx (factory: TxFactory, issue: Issue, nextStatus: Ref<Status>): Tx {
|
||||
const innerTx = factory.createTxUpdateDoc(issue._class, issue.space, issue._id, {
|
||||
status: nextStatus
|
||||
})
|
||||
return factory.createTxCollectionCUD(issue.attachedToClass, issue.attachedTo, issue.space, issue.collection, innerTx)
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -476,53 +546,11 @@ export async function IssueToDoDone (
|
||||
)[0]
|
||||
if (issue !== undefined) {
|
||||
if (!isDerived) {
|
||||
const project = (await control.findAll(control.ctx, task.class.Project, { _id: issue.space }))[0]
|
||||
if (project !== undefined) {
|
||||
const type = (await control.modelDb.findAll(task.class.ProjectType, { _id: project.type }))[0]
|
||||
if (type?.classic) {
|
||||
const taskType = (await control.modelDb.findAll(task.class.TaskType, { _id: issue.kind }))[0]
|
||||
if (taskType !== undefined) {
|
||||
const index = taskType.statuses.findIndex((p) => p === issue.status)
|
||||
|
||||
const helpers = await control.modelDb.findAll<TodoAutomationHelper>(time.class.TodoAutomationHelper, {})
|
||||
const testers = await Promise.all(helpers.map((it) => getResource(it.onDoneTester)))
|
||||
let allowed = true
|
||||
for (const tester of testers) {
|
||||
if (!(await tester(control, todo))) {
|
||||
allowed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (index !== -1 && allowed) {
|
||||
const nextStatus = taskType.statuses[index + 1]
|
||||
if (nextStatus !== undefined) {
|
||||
const currentStatus = taskType.statuses[index]
|
||||
const current = (await control.modelDb.findAll(core.class.Status, { _id: currentStatus }))[0]
|
||||
const next = (await control.modelDb.findAll(core.class.Status, { _id: nextStatus }))[0]
|
||||
if (
|
||||
current.category !== task.statusCategory.Lost &&
|
||||
next.category !== task.statusCategory.Lost &&
|
||||
current.category !== task.statusCategory.Won
|
||||
) {
|
||||
const innerTx = factory.createTxUpdateDoc(issue._class, issue.space, issue._id, {
|
||||
status: nextStatus
|
||||
})
|
||||
const outerTx = factory.createTxCollectionCUD(
|
||||
issue.attachedToClass,
|
||||
issue.attachedTo,
|
||||
issue.space,
|
||||
issue.collection,
|
||||
innerTx
|
||||
)
|
||||
res.push(outerTx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const changeTx = await generateChangeStatusTx(control, factory, issue, todo)
|
||||
if (changeTx !== undefined) {
|
||||
res.push(changeTx)
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
// round to nearest 15 minutes
|
||||
total = Math.round(total / 15) * 15
|
||||
|
@ -1712,7 +1712,7 @@ export async function getAllWorkspaces (
|
||||
}
|
||||
|
||||
return (await db.workspace.find({})).map((it) => {
|
||||
it.accounts = (it.accounts ?? []).map((it) => it.toString())
|
||||
;(it as any).accounts = (it.accounts ?? []).length
|
||||
return it
|
||||
})
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ import {
|
||||
} from '@hcengineering/core'
|
||||
import type { Asset, Resource } from '@hcengineering/platform'
|
||||
import type { LiveQuery } from '@hcengineering/query'
|
||||
import type { Request, Response } from '@hcengineering/rpc'
|
||||
import type { ReqId, Request, Response } from '@hcengineering/rpc'
|
||||
import type { Token } from '@hcengineering/server-token'
|
||||
import { type Readable } from 'stream'
|
||||
import type { DbAdapter, DomainHelper } from './adapter'
|
||||
@ -162,6 +162,11 @@ export interface DBAdapterManager {
|
||||
|
||||
export interface PipelineContext {
|
||||
workspace: WorkspaceIdWithUrl
|
||||
|
||||
lastTx?: string
|
||||
|
||||
lastHash?: string
|
||||
|
||||
hierarchy: Hierarchy
|
||||
modelDb: ModelDb
|
||||
branding: Branding | null
|
||||
@ -496,32 +501,21 @@ export interface SessionRequest {
|
||||
|
||||
export interface ClientSessionCtx {
|
||||
ctx: MeasureContext
|
||||
sendResponse: (msg: any) => Promise<void>
|
||||
|
||||
pipeline: Pipeline
|
||||
|
||||
requestId: ReqId | undefined
|
||||
sendResponse: (id: ReqId | undefined, msg: any) => Promise<void>
|
||||
sendPong: () => void
|
||||
sendError: (msg: any, error: any) => Promise<void>
|
||||
sendError: (id: ReqId | undefined, msg: any, error: any) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Session {
|
||||
workspace: Workspace
|
||||
createTime: number
|
||||
getUser: () => string
|
||||
pipeline: () => Pipeline
|
||||
ping: (ctx: ClientSessionCtx) => Promise<void>
|
||||
findAll: <T extends Doc>(
|
||||
ctx: ClientSessionCtx,
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
) => Promise<void>
|
||||
findAllRaw: <T extends Doc>(
|
||||
ctx: MeasureContext,
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
) => Promise<FindResult<T>>
|
||||
tx: (ctx: ClientSessionCtx, tx: Tx) => Promise<void>
|
||||
|
||||
// Session restore information
|
||||
sessionId: string
|
||||
@ -544,6 +538,28 @@ export interface Session {
|
||||
getMode: () => string
|
||||
|
||||
broadcast: (ctx: MeasureContext, socket: ConnectionSocket, tx: Tx[]) => void
|
||||
|
||||
// Client methods
|
||||
ping: (ctx: ClientSessionCtx) => Promise<void>
|
||||
getUser: () => string
|
||||
|
||||
loadModel: (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string) => Promise<void>
|
||||
getAccount: (ctx: ClientSessionCtx) => Promise<void>
|
||||
|
||||
getRawAccount: (pipeline: Pipeline) => Account
|
||||
findAll: <T extends Doc>(
|
||||
ctx: ClientSessionCtx,
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
) => Promise<void>
|
||||
searchFulltext: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise<void>
|
||||
tx: (ctx: ClientSessionCtx, tx: Tx) => Promise<void>
|
||||
loadChunk: (ctx: ClientSessionCtx, domain: Domain, idx?: number) => Promise<void>
|
||||
closeChunk: (ctx: ClientSessionCtx, idx: number) => Promise<void>
|
||||
loadDocs: (ctx: ClientSessionCtx, domain: Domain, docs: Ref<Doc>[]) => Promise<void>
|
||||
upload: (ctx: ClientSessionCtx, domain: Domain, docs: Doc[]) => Promise<void>
|
||||
clean: (ctx: ClientSessionCtx, domain: Domain, docs: Ref<Doc>[]) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -587,7 +603,7 @@ export interface Workspace {
|
||||
context: MeasureContext
|
||||
id: string
|
||||
token: string // Account workspace update token.
|
||||
pipeline: Promise<Pipeline>
|
||||
pipeline: Promise<Pipeline> | Pipeline
|
||||
tickHash: number
|
||||
|
||||
tickHandlers: Map<string, TickHandler>
|
||||
@ -599,7 +615,7 @@ export interface Workspace {
|
||||
softShutdown: number
|
||||
workspaceInitCompleted: boolean
|
||||
|
||||
workspaceId: WorkspaceId
|
||||
workspaceId: WorkspaceIdWithUrl
|
||||
workspaceName: string
|
||||
workspaceUuid?: string
|
||||
branding: Branding | null
|
||||
@ -622,12 +638,7 @@ export interface SessionManager {
|
||||
workspaces: Map<string, Workspace>
|
||||
sessions: Map<string, { session: Session, socket: ConnectionSocket }>
|
||||
|
||||
createSession: (
|
||||
token: Token,
|
||||
pipeline: Pipeline,
|
||||
workspaceId: WorkspaceIdWithUrl,
|
||||
branding: Branding | null
|
||||
) => Session
|
||||
createSession: (token: Token, workspace: Workspace) => Session
|
||||
|
||||
addSession: (
|
||||
ctx: MeasureContext,
|
||||
|
@ -167,20 +167,51 @@ export function getUser (modelDb: ModelDb, userEmail: string | undefined, admin?
|
||||
|
||||
export class SessionDataImpl implements SessionData {
|
||||
_account: Account | undefined
|
||||
_removedMap: Map<Ref<Doc>, Doc> | undefined
|
||||
_contextCache: Map<string, any> | undefined
|
||||
_broadcast: SessionData['broadcast'] | undefined
|
||||
|
||||
constructor (
|
||||
readonly userEmail: string,
|
||||
readonly sessionId: string,
|
||||
readonly admin: boolean | undefined,
|
||||
readonly broadcast: SessionData['broadcast'],
|
||||
_broadcast: SessionData['broadcast'] | undefined,
|
||||
readonly workspace: WorkspaceIdWithUrl,
|
||||
readonly branding: Branding | null,
|
||||
readonly isAsyncContext: boolean,
|
||||
readonly removedMap: Map<Ref<Doc>, Doc>,
|
||||
readonly contextCache: Map<string, any>,
|
||||
_removedMap: Map<Ref<Doc>, Doc> | undefined,
|
||||
_contextCache: Map<string, any> | undefined,
|
||||
readonly modelDb: ModelDb,
|
||||
readonly rawAccount?: Account
|
||||
) {}
|
||||
) {
|
||||
this._removedMap = _removedMap
|
||||
this._contextCache = _contextCache
|
||||
this._broadcast = _broadcast
|
||||
}
|
||||
|
||||
get broadcast (): SessionData['broadcast'] {
|
||||
if (this._broadcast === undefined) {
|
||||
this._broadcast = {
|
||||
targets: {},
|
||||
txes: []
|
||||
}
|
||||
}
|
||||
return this._broadcast
|
||||
}
|
||||
|
||||
get removedMap (): Map<Ref<Doc>, Doc> {
|
||||
if (this._removedMap === undefined) {
|
||||
this._removedMap = new Map()
|
||||
}
|
||||
return this._removedMap
|
||||
}
|
||||
|
||||
get contextCache (): Map<string, any> {
|
||||
if (this._contextCache === undefined) {
|
||||
this._contextCache = new Map()
|
||||
}
|
||||
return this._contextCache
|
||||
}
|
||||
|
||||
get account (): Account {
|
||||
this._account = this.rawAccount ?? this._account ?? getUser(this.modelDb, this.userEmail, this.admin)
|
||||
@ -234,8 +265,8 @@ export function wrapPipeline (
|
||||
wsUrl,
|
||||
null,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
undefined,
|
||||
undefined,
|
||||
pipeline.context.modelDb
|
||||
)
|
||||
ctx.contextData = contextData
|
||||
|
@ -759,12 +759,12 @@ export class FullTextIndexPipeline implements FullTextPipeline {
|
||||
systemAccountEmail,
|
||||
'',
|
||||
true,
|
||||
{ targets: {}, txes: [] },
|
||||
undefined,
|
||||
this.workspace,
|
||||
null,
|
||||
false,
|
||||
new Map(),
|
||||
new Map(),
|
||||
undefined,
|
||||
undefined,
|
||||
this.model
|
||||
)
|
||||
}
|
||||
|
@ -115,6 +115,7 @@ export class ModelMiddleware extends BaseMiddleware implements Middleware {
|
||||
|
||||
private setLastHash (hash: string): void {
|
||||
this.lastHash = hash
|
||||
this.context.lastHash = this.lastHash
|
||||
this.lastHashResponse = Promise.resolve({
|
||||
full: false,
|
||||
hash,
|
||||
|
@ -16,12 +16,16 @@
|
||||
import core, {
|
||||
DOMAIN_TRANSIENT,
|
||||
DOMAIN_TX,
|
||||
generateId,
|
||||
TxProcessor,
|
||||
WorkspaceEvent,
|
||||
type Doc,
|
||||
type MeasureContext,
|
||||
type SessionData,
|
||||
type Tx,
|
||||
type TxCUD,
|
||||
type TxResult
|
||||
type TxResult,
|
||||
type TxWorkspaceEvent
|
||||
} from '@hcengineering/core'
|
||||
import { PlatformError, unknownError } from '@hcengineering/platform'
|
||||
import type { DBAdapterManager, Middleware, PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core'
|
||||
@ -68,6 +72,22 @@ export class TxMiddleware extends BaseMiddleware implements Middleware {
|
||||
txes: Array.from(new Set(txToStore.map((it) => it._class)))
|
||||
}
|
||||
)
|
||||
// We need to remember last Tx Id in context, so it will be used during reconnect to track a requirement for refresh.
|
||||
this.context.lastTx = txToStore[txToStore.length - 1]._id
|
||||
// We need to deliver information to all clients so far.
|
||||
const evt: TxWorkspaceEvent = {
|
||||
_class: core.class.TxWorkspaceEvent,
|
||||
_id: generateId(),
|
||||
event: WorkspaceEvent.LastTx,
|
||||
modifiedBy: core.account.System,
|
||||
modifiedOn: Date.now(),
|
||||
objectSpace: core.space.DerivedTx,
|
||||
space: core.space.DerivedTx,
|
||||
params: {
|
||||
lastTx: this.context.lastTx
|
||||
}
|
||||
}
|
||||
;(ctx.contextData as SessionData).broadcast.txes.push(evt)
|
||||
}
|
||||
if (txPromise !== undefined) {
|
||||
await txPromise
|
||||
|
@ -46,6 +46,8 @@ export interface HelloResponse extends Response<any> {
|
||||
binary: boolean
|
||||
reconnect?: boolean
|
||||
serverVersion: string
|
||||
lastTx?: string
|
||||
lastHash?: string // Last model hash
|
||||
}
|
||||
|
||||
function replacer (key: string, value: any): any {
|
||||
|
@ -13,13 +13,10 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import core, {
|
||||
AccountRole,
|
||||
import {
|
||||
generateId,
|
||||
TxFactory,
|
||||
TxProcessor,
|
||||
type Account,
|
||||
type Branding,
|
||||
type Class,
|
||||
type Doc,
|
||||
type DocumentQuery,
|
||||
@ -33,20 +30,21 @@ import core, {
|
||||
type SessionData,
|
||||
type Timestamp,
|
||||
type Tx,
|
||||
type TxCUD,
|
||||
type WorkspaceIdWithUrl
|
||||
type TxCUD
|
||||
} from '@hcengineering/core'
|
||||
import { PlatformError, unknownError } from '@hcengineering/platform'
|
||||
import {
|
||||
BackupClientOps,
|
||||
createBroadcastEvent,
|
||||
getUser,
|
||||
SessionDataImpl,
|
||||
type ClientSessionCtx,
|
||||
type ConnectionSocket,
|
||||
type Pipeline,
|
||||
type Session,
|
||||
type SessionRequest,
|
||||
type StatisticsElement
|
||||
type StatisticsElement,
|
||||
type Workspace
|
||||
} from '@hcengineering/server-core'
|
||||
import { type Token } from '@hcengineering/server-token'
|
||||
import { handleSend } from './utils'
|
||||
@ -72,14 +70,18 @@ export class ClientSession implements Session {
|
||||
measures: { id: string, message: string, time: 0 }[] = []
|
||||
|
||||
ops: BackupClientOps | undefined
|
||||
opsPipeline: Pipeline | undefined
|
||||
|
||||
account?: Account
|
||||
isAdmin: boolean
|
||||
|
||||
constructor (
|
||||
protected readonly token: Token,
|
||||
protected readonly _pipeline: Pipeline,
|
||||
readonly workspaceId: WorkspaceIdWithUrl,
|
||||
readonly branding: Branding | null,
|
||||
readonly workspace: Workspace,
|
||||
readonly allowUpload: boolean
|
||||
) {}
|
||||
) {
|
||||
this.isAdmin = this.token.extra?.admin === 'true'
|
||||
}
|
||||
|
||||
getUser (): string {
|
||||
return this.token.email
|
||||
@ -93,76 +95,48 @@ export class ClientSession implements Session {
|
||||
return this.token.extra?.mode ?? 'normal'
|
||||
}
|
||||
|
||||
pipeline (): Pipeline {
|
||||
return this._pipeline
|
||||
}
|
||||
|
||||
async ping (ctx: ClientSessionCtx): Promise<void> {
|
||||
this.lastRequest = Date.now()
|
||||
ctx.sendPong()
|
||||
}
|
||||
|
||||
async loadModel (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string): Promise<void> {
|
||||
this.includeSessionContext(ctx.ctx)
|
||||
const result = await ctx.ctx.with('load-model', {}, () => this._pipeline.loadModel(ctx.ctx, lastModelTx, hash))
|
||||
await ctx.sendResponse(result)
|
||||
this.includeSessionContext(ctx.ctx, ctx.pipeline)
|
||||
const result = await ctx.ctx.with('load-model', {}, () => ctx.pipeline.loadModel(ctx.ctx, lastModelTx, hash))
|
||||
await ctx.sendResponse(ctx.requestId, result)
|
||||
}
|
||||
|
||||
async getAccount (ctx: ClientSessionCtx): Promise<void> {
|
||||
const account = this._pipeline.context.modelDb.getAccountByEmail(this.token.email)
|
||||
if (account === undefined && this.token.extra?.admin === 'true') {
|
||||
await ctx.sendResponse(this.getSystemAccount())
|
||||
return
|
||||
}
|
||||
await ctx.sendResponse(account)
|
||||
await ctx.sendResponse(ctx.requestId, this.getRawAccount(ctx.pipeline))
|
||||
}
|
||||
|
||||
private getSystemAccount (): Account {
|
||||
// Generate account for admin user
|
||||
const factory = new TxFactory(core.account.System)
|
||||
const email = `system:${this.token.email}`
|
||||
const createTx = factory.createTxCreateDoc(
|
||||
core.class.Account,
|
||||
core.space.Model,
|
||||
{
|
||||
role: AccountRole.Owner,
|
||||
email
|
||||
},
|
||||
email as Ref<Account>
|
||||
)
|
||||
return TxProcessor.createDoc2Doc(createTx)
|
||||
getRawAccount (pipeline: Pipeline): Account {
|
||||
if (this.account === undefined) {
|
||||
this.account = getUser(pipeline.context.modelDb, this.token.email, this.isAdmin)
|
||||
}
|
||||
return this.account
|
||||
}
|
||||
|
||||
includeSessionContext (ctx: MeasureContext): void {
|
||||
let account: Account | undefined
|
||||
if (this.token.extra?.admin === 'true') {
|
||||
account = this._pipeline.context.modelDb.getAccountByEmail(this.token.email)
|
||||
if (account === undefined) {
|
||||
account = this.getSystemAccount()
|
||||
}
|
||||
}
|
||||
|
||||
includeSessionContext (ctx: MeasureContext, pipeline: Pipeline): void {
|
||||
const contextData = new SessionDataImpl(
|
||||
this.token.email,
|
||||
this.sessionId,
|
||||
this.token.extra?.admin === 'true',
|
||||
{
|
||||
txes: [],
|
||||
targets: {}
|
||||
},
|
||||
this.workspaceId,
|
||||
this.branding,
|
||||
this.isAdmin,
|
||||
undefined,
|
||||
this.workspace.workspaceId,
|
||||
this.workspace.branding,
|
||||
false,
|
||||
new Map(),
|
||||
new Map(),
|
||||
this._pipeline.context.modelDb,
|
||||
account
|
||||
undefined,
|
||||
undefined,
|
||||
pipeline.context.modelDb,
|
||||
this.getRawAccount(pipeline)
|
||||
)
|
||||
ctx.contextData = contextData
|
||||
}
|
||||
|
||||
findAllRaw<T extends Doc>(
|
||||
ctx: MeasureContext,
|
||||
pipeline: Pipeline,
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
@ -170,8 +144,8 @@ export class ClientSession implements Session {
|
||||
this.lastRequest = Date.now()
|
||||
this.total.find++
|
||||
this.current.find++
|
||||
this.includeSessionContext(ctx)
|
||||
return this._pipeline.findAll(ctx, _class, query, options)
|
||||
this.includeSessionContext(ctx, pipeline)
|
||||
return pipeline.findAll(ctx, _class, query, options)
|
||||
}
|
||||
|
||||
async findAll<T extends Doc>(
|
||||
@ -180,32 +154,32 @@ export class ClientSession implements Session {
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
): Promise<void> {
|
||||
await ctx.sendResponse(await this.findAllRaw(ctx.ctx, _class, query, options))
|
||||
await ctx.sendResponse(ctx.requestId, await this.findAllRaw(ctx.ctx, ctx.pipeline, _class, query, options))
|
||||
}
|
||||
|
||||
async searchFulltext (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions): Promise<void> {
|
||||
this.lastRequest = Date.now()
|
||||
this.includeSessionContext(ctx.ctx)
|
||||
await ctx.sendResponse(await this._pipeline.searchFulltext(ctx.ctx, query, options))
|
||||
this.includeSessionContext(ctx.ctx, ctx.pipeline)
|
||||
await ctx.sendResponse(ctx.requestId, await ctx.pipeline.searchFulltext(ctx.ctx, query, options))
|
||||
}
|
||||
|
||||
async tx (ctx: ClientSessionCtx, tx: Tx): Promise<void> {
|
||||
this.lastRequest = Date.now()
|
||||
this.total.tx++
|
||||
this.current.tx++
|
||||
this.includeSessionContext(ctx.ctx)
|
||||
this.includeSessionContext(ctx.ctx, ctx.pipeline)
|
||||
|
||||
let cid = 'client_' + generateId()
|
||||
ctx.ctx.id = cid
|
||||
let onEnd = useReserveContext ? this._pipeline.context.adapterManager?.reserveContext?.(cid) : undefined
|
||||
let onEnd = useReserveContext ? ctx.pipeline.context.adapterManager?.reserveContext?.(cid) : undefined
|
||||
try {
|
||||
const result = await this._pipeline.tx(ctx.ctx, [tx])
|
||||
const result = await ctx.pipeline.tx(ctx.ctx, [tx])
|
||||
|
||||
// Send result immideately
|
||||
await ctx.sendResponse(result)
|
||||
await ctx.sendResponse(ctx.requestId, result)
|
||||
|
||||
// We need to broadcast all collected transactions
|
||||
await this._pipeline.handleBroadcast(ctx.ctx)
|
||||
await ctx.pipeline.handleBroadcast(ctx.ctx)
|
||||
} finally {
|
||||
onEnd?.()
|
||||
}
|
||||
@ -215,7 +189,7 @@ export class ClientSession implements Session {
|
||||
if (asyncs.length > 0) {
|
||||
cid = 'client_async_' + generateId()
|
||||
ctx.ctx.id = cid
|
||||
onEnd = useReserveContext ? this._pipeline.context.adapterManager?.reserveContext?.(cid) : undefined
|
||||
onEnd = useReserveContext ? ctx.pipeline.context.adapterManager?.reserveContext?.(cid) : undefined
|
||||
try {
|
||||
for (const r of (ctx.ctx.contextData as SessionData).asyncRequests ?? []) {
|
||||
await r()
|
||||
@ -252,12 +226,13 @@ export class ClientSession implements Session {
|
||||
}
|
||||
}
|
||||
|
||||
getOps (): BackupClientOps {
|
||||
if (this.ops === undefined) {
|
||||
if (this._pipeline.context.lowLevelStorage === undefined) {
|
||||
getOps (pipeline: Pipeline): BackupClientOps {
|
||||
if (this.ops === undefined || this.opsPipeline !== pipeline) {
|
||||
if (pipeline.context.lowLevelStorage === undefined) {
|
||||
throw new PlatformError(unknownError('Low level storage is not available'))
|
||||
}
|
||||
this.ops = new BackupClientOps(this._pipeline.context.lowLevelStorage)
|
||||
this.ops = new BackupClientOps(pipeline.context.lowLevelStorage)
|
||||
this.opsPipeline = pipeline
|
||||
}
|
||||
return this.ops
|
||||
}
|
||||
@ -265,67 +240,58 @@ export class ClientSession implements Session {
|
||||
async loadChunk (ctx: ClientSessionCtx, domain: Domain, idx?: number): Promise<void> {
|
||||
this.lastRequest = Date.now()
|
||||
try {
|
||||
const result = await this.getOps().loadChunk(ctx.ctx, domain, idx)
|
||||
await ctx.sendResponse(result)
|
||||
const result = await this.getOps(ctx.pipeline).loadChunk(ctx.ctx, domain, idx)
|
||||
await ctx.sendResponse(ctx.requestId, result)
|
||||
} catch (err: any) {
|
||||
await ctx.sendError('Failed to upload', unknownError(err))
|
||||
await ctx.sendError(ctx.requestId, 'Failed to upload', unknownError(err))
|
||||
ctx.ctx.error('failed to loadChunk', { domain, err })
|
||||
}
|
||||
}
|
||||
|
||||
async closeChunk (ctx: ClientSessionCtx, idx: number): Promise<void> {
|
||||
this.lastRequest = Date.now()
|
||||
await this.getOps().closeChunk(ctx.ctx, idx)
|
||||
await ctx.sendResponse({})
|
||||
await this.getOps(ctx.pipeline).closeChunk(ctx.ctx, idx)
|
||||
await ctx.sendResponse(ctx.requestId, {})
|
||||
}
|
||||
|
||||
async loadDocs (ctx: ClientSessionCtx, domain: Domain, docs: Ref<Doc>[]): Promise<void> {
|
||||
this.lastRequest = Date.now()
|
||||
try {
|
||||
const result = await this.getOps().loadDocs(ctx.ctx, domain, docs)
|
||||
await ctx.sendResponse(result)
|
||||
const result = await this.getOps(ctx.pipeline).loadDocs(ctx.ctx, domain, docs)
|
||||
await ctx.sendResponse(ctx.requestId, result)
|
||||
} catch (err: any) {
|
||||
await ctx.sendError('Failed to loadDocs', unknownError(err))
|
||||
await ctx.sendError(ctx.requestId, 'Failed to loadDocs', unknownError(err))
|
||||
ctx.ctx.error('failed to loadDocs', { domain, err })
|
||||
}
|
||||
}
|
||||
|
||||
async upload (ctx: ClientSessionCtx, domain: Domain, docs: Doc[]): Promise<void> {
|
||||
if (!this.allowUpload) {
|
||||
await ctx.sendResponse({ error: 'Upload not allowed' })
|
||||
await ctx.sendResponse(ctx.requestId, { error: 'Upload not allowed' })
|
||||
}
|
||||
this.lastRequest = Date.now()
|
||||
try {
|
||||
await this.getOps().upload(ctx.ctx, domain, docs)
|
||||
await this.getOps(ctx.pipeline).upload(ctx.ctx, domain, docs)
|
||||
} catch (err: any) {
|
||||
await ctx.sendError('Failed to upload', unknownError(err))
|
||||
await ctx.sendError(ctx.requestId, 'Failed to upload', unknownError(err))
|
||||
ctx.ctx.error('failed to loadDocs', { domain, err })
|
||||
return
|
||||
}
|
||||
await ctx.sendResponse({})
|
||||
await ctx.sendResponse(ctx.requestId, {})
|
||||
}
|
||||
|
||||
async clean (ctx: ClientSessionCtx, domain: Domain, docs: Ref<Doc>[]): Promise<void> {
|
||||
if (!this.allowUpload) {
|
||||
await ctx.sendResponse({ error: 'Clean not allowed' })
|
||||
await ctx.sendResponse(ctx.requestId, { error: 'Clean not allowed' })
|
||||
}
|
||||
this.lastRequest = Date.now()
|
||||
try {
|
||||
await this.getOps().clean(ctx.ctx, domain, docs)
|
||||
await this.getOps(ctx.pipeline).clean(ctx.ctx, domain, docs)
|
||||
} catch (err: any) {
|
||||
await ctx.sendError('Failed to clean', unknownError(err))
|
||||
await ctx.sendError(ctx.requestId, 'Failed to clean', unknownError(err))
|
||||
ctx.ctx.error('failed to clean', { domain, err })
|
||||
return
|
||||
}
|
||||
await ctx.sendResponse({})
|
||||
await ctx.sendResponse(ctx.requestId, {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface BackupSession extends Session {
|
||||
loadChunk: (ctx: ClientSessionCtx, domain: Domain, idx?: number) => Promise<void>
|
||||
closeChunk: (ctx: ClientSessionCtx, idx: number) => Promise<void>
|
||||
loadDocs: (ctx: ClientSessionCtx, domain: Domain, docs: Ref<Doc>[]) => Promise<void>
|
||||
}
|
||||
|
@ -33,8 +33,7 @@ import core, {
|
||||
type MeasureContext,
|
||||
type Tx,
|
||||
type TxWorkspaceEvent,
|
||||
type WorkspaceId,
|
||||
type WorkspaceIdWithUrl
|
||||
type WorkspaceId
|
||||
} from '@hcengineering/core'
|
||||
import { unknownError, type Status } from '@hcengineering/platform'
|
||||
import { type HelloRequest, type HelloResponse, type Request, type Response } from '@hcengineering/rpc'
|
||||
@ -109,12 +108,7 @@ class TSessionManager implements SessionManager {
|
||||
|
||||
constructor (
|
||||
readonly ctx: MeasureContext,
|
||||
readonly sessionFactory: (
|
||||
token: Token,
|
||||
pipeline: Pipeline,
|
||||
workspaceId: WorkspaceIdWithUrl,
|
||||
branding: Branding | null
|
||||
) => Session,
|
||||
readonly sessionFactory: (token: Token, workspace: Workspace) => Session,
|
||||
readonly timeouts: Timeouts,
|
||||
readonly brandingMap: BrandingMap,
|
||||
readonly profiling:
|
||||
@ -288,8 +282,8 @@ class TSessionManager implements SessionManager {
|
||||
this.ticks++
|
||||
}
|
||||
|
||||
createSession (token: Token, pipeline: Pipeline, workspaceId: WorkspaceIdWithUrl, branding: Branding | null): Session {
|
||||
return this.sessionFactory(token, pipeline, workspaceId, branding)
|
||||
createSession (token: Token, workspace: Workspace): Session {
|
||||
return this.sessionFactory(token, workspace)
|
||||
}
|
||||
|
||||
async getWorkspaceInfo (ctx: MeasureContext, token: string): Promise<WorkspaceLoginInfo | undefined> {
|
||||
@ -473,7 +467,12 @@ class TSessionManager implements SessionManager {
|
||||
return { upgrade: true }
|
||||
}
|
||||
try {
|
||||
pipeline = await ctx.with('💤 wait', { workspaceName }, () => (workspace as Workspace).pipeline)
|
||||
if (workspace.pipeline instanceof Promise) {
|
||||
pipeline = await workspace.pipeline
|
||||
workspace.pipeline = pipeline
|
||||
} else {
|
||||
pipeline = workspace.pipeline
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Failed to create pipeline, etc
|
||||
Analytics.handleError(err)
|
||||
@ -482,16 +481,7 @@ class TSessionManager implements SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
const session = this.createSession(
|
||||
token,
|
||||
pipeline,
|
||||
{
|
||||
...workspace.workspaceId,
|
||||
workspaceName: workspaceInfo.workspaceName ?? '',
|
||||
workspaceUrl: workspaceInfo.workspaceUrl ?? ''
|
||||
},
|
||||
branding
|
||||
)
|
||||
const session = this.createSession(token, workspace)
|
||||
|
||||
session.sessionId = sessionId !== undefined && (sessionId ?? '').trim().length > 0 ? sessionId : generateId()
|
||||
session.sessionInstanceId = generateId()
|
||||
@ -508,7 +498,7 @@ class TSessionManager implements SessionManager {
|
||||
// We do not need to wait for set-status, just return session to client
|
||||
const _workspace = workspace
|
||||
void ctx
|
||||
.with('set-status', {}, (ctx) => this.trySetStatus(ctx, session, true, _workspace.workspaceId))
|
||||
.with('set-status', {}, (ctx) => this.trySetStatus(ctx, pipeline, session, true, _workspace.workspaceId))
|
||||
.catch(() => {})
|
||||
|
||||
if (this.timeMinutes > 0) {
|
||||
@ -559,6 +549,7 @@ class TSessionManager implements SessionManager {
|
||||
// Drop all existing clients
|
||||
workspace.closing = this.closeAll(wsString, workspace, 0, 'upgrade')
|
||||
await workspace.closing
|
||||
workspace.closing = undefined
|
||||
// Wipe workspace and update values.
|
||||
workspace.workspaceName = workspaceName
|
||||
if (!workspace.upgrade) {
|
||||
@ -661,6 +652,7 @@ class TSessionManager implements SessionManager {
|
||||
workspaceUuid: string | undefined,
|
||||
branding: Branding | null
|
||||
): Workspace {
|
||||
const wsId = toWorkspaceString(token.workspace)
|
||||
const upgrade = token.extra?.model === 'upgrade'
|
||||
const context = ctx.newChild('🧲 session', {})
|
||||
const pipelineCtx = context.newChild('🧲 pipeline-factory', {})
|
||||
@ -679,7 +671,11 @@ class TSessionManager implements SessionManager {
|
||||
sessions: new Map(),
|
||||
softShutdown: workspaceSoftShutdownTicks,
|
||||
upgrade,
|
||||
workspaceId: token.workspace,
|
||||
workspaceId: {
|
||||
...token.workspace,
|
||||
workspaceName,
|
||||
workspaceUrl
|
||||
},
|
||||
workspaceName,
|
||||
workspaceUuid,
|
||||
branding,
|
||||
@ -688,12 +684,13 @@ class TSessionManager implements SessionManager {
|
||||
tickHandlers: new Map(),
|
||||
token: generateToken(systemAccountEmail, token.workspace)
|
||||
}
|
||||
this.workspaces.set(toWorkspaceString(token.workspace), workspace)
|
||||
this.workspaces.set(wsId, workspace)
|
||||
return workspace
|
||||
}
|
||||
|
||||
private async trySetStatus (
|
||||
ctx: MeasureContext,
|
||||
pipeline: Pipeline,
|
||||
session: Session,
|
||||
online: boolean,
|
||||
workspaceId: WorkspaceId
|
||||
@ -702,7 +699,7 @@ class TSessionManager implements SessionManager {
|
||||
if (current !== undefined) {
|
||||
await current
|
||||
}
|
||||
const promise = this.setStatus(ctx, session, online, workspaceId)
|
||||
const promise = this.setStatus(ctx, pipeline, session, online, workspaceId)
|
||||
this.statusPromises.set(session.getUser(), promise)
|
||||
await promise
|
||||
this.statusPromises.delete(session.getUser())
|
||||
@ -710,38 +707,28 @@ class TSessionManager implements SessionManager {
|
||||
|
||||
private async setStatus (
|
||||
ctx: MeasureContext,
|
||||
pipeline: Pipeline,
|
||||
session: Session,
|
||||
online: boolean,
|
||||
workspaceId: WorkspaceId
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = session.pipeline().context.modelDb.getAccountByEmail(session.getUser())
|
||||
const user = pipeline.context.modelDb.getAccountByEmail(session.getUser())
|
||||
if (user === undefined) return
|
||||
|
||||
const clientCtx: ClientSessionCtx = {
|
||||
sendResponse: async (msg) => {
|
||||
// No response
|
||||
},
|
||||
ctx,
|
||||
sendError: async (msg, error: Status) => {
|
||||
// Assume no error send
|
||||
},
|
||||
sendPong: () => {}
|
||||
}
|
||||
|
||||
const status = (await session.findAllRaw(ctx, core.class.UserStatus, { user: user._id }, { limit: 1 }))[0]
|
||||
const status = (await pipeline.findAll(ctx, core.class.UserStatus, { user: user._id }, { limit: 1 }))[0]
|
||||
const txFactory = new TxFactory(user._id, true)
|
||||
if (status === undefined) {
|
||||
const tx = txFactory.createTxCreateDoc(core.class.UserStatus, core.space.Space, {
|
||||
online,
|
||||
user: user._id
|
||||
})
|
||||
await session.tx(clientCtx, tx)
|
||||
await pipeline.tx(ctx, [tx])
|
||||
} else if (status.online !== online) {
|
||||
const tx = txFactory.createTxUpdateDoc(status._class, status.space, status._id, {
|
||||
online
|
||||
})
|
||||
await session.tx(clientCtx, tx)
|
||||
await pipeline.tx(ctx, [tx])
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@ -763,6 +750,7 @@ class TSessionManager implements SessionManager {
|
||||
this.sessions.delete(ws.id)
|
||||
if (workspace !== undefined) {
|
||||
workspace.sessions.delete(sessionRef.session.sessionId)
|
||||
const pipeline = workspace.pipeline instanceof Promise ? await workspace.pipeline : workspace.pipeline
|
||||
|
||||
workspace.tickHandlers.set(sessionRef.session.sessionId, {
|
||||
ticks: this.timeouts.reconnectTimeout * ticksPerSecond,
|
||||
@ -773,9 +761,13 @@ class TSessionManager implements SessionManager {
|
||||
if (workspace !== undefined) {
|
||||
const another = Array.from(workspace.sessions.values()).findIndex((p) => p.session.getUser() === user)
|
||||
if (another === -1 && !workspace.upgrade) {
|
||||
void this.trySetStatus(workspace.context, sessionRef.session, false, workspace.workspaceId).catch(
|
||||
() => {}
|
||||
)
|
||||
void this.trySetStatus(
|
||||
workspace.context,
|
||||
pipeline,
|
||||
sessionRef.session,
|
||||
false,
|
||||
workspace.workspaceId
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -795,8 +787,9 @@ class TSessionManager implements SessionManager {
|
||||
if (ws !== undefined) {
|
||||
ws.upgrade = true // We need to similare upgrade to refresh all clients.
|
||||
ws.closing = this.closeAll(wsId, ws, 99, 'force-close', ignoreSocket)
|
||||
await ws.closing
|
||||
this.workspaces.delete(wsId)
|
||||
await ws.closing
|
||||
ws.closing = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@ -827,6 +820,7 @@ class TSessionManager implements SessionManager {
|
||||
this.sendUpgrade(workspace.context, webSocket, s.binaryMode)
|
||||
}
|
||||
webSocket.close()
|
||||
this.reconnectIds.delete(s.sessionId)
|
||||
}
|
||||
|
||||
if (LOGGING_ENABLED) {
|
||||
@ -929,6 +923,41 @@ class TSessionManager implements SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
createOpContext (
|
||||
ctx: MeasureContext,
|
||||
pipeline: Pipeline,
|
||||
request: Request<any>,
|
||||
service: Session,
|
||||
ws: ConnectionSocket
|
||||
): ClientSessionCtx {
|
||||
const st = Date.now()
|
||||
return {
|
||||
ctx,
|
||||
pipeline,
|
||||
requestId: request.id,
|
||||
sendResponse: (reqId, msg) =>
|
||||
sendResponse(ctx, service, ws, {
|
||||
id: reqId,
|
||||
result: msg,
|
||||
time: Date.now() - st,
|
||||
bfst: Date.now(),
|
||||
queue: service.requests.size
|
||||
}),
|
||||
sendPong: () => {
|
||||
ws.sendPong()
|
||||
},
|
||||
sendError: (reqId, msg, error: Status) =>
|
||||
sendResponse(ctx, service, ws, {
|
||||
id: reqId,
|
||||
result: msg,
|
||||
error,
|
||||
time: Date.now() - st,
|
||||
bfst: Date.now(),
|
||||
queue: service.requests.size
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handleRequest<S extends Session>(
|
||||
requestCtx: MeasureContext,
|
||||
service: S,
|
||||
@ -936,43 +965,38 @@ class TSessionManager implements SessionManager {
|
||||
request: Request<any>,
|
||||
workspace: string // wsId, toWorkspaceString()
|
||||
): Promise<void> {
|
||||
const backupMode = service.getMode() === 'backup'
|
||||
|
||||
const userCtx = requestCtx.newChild(
|
||||
'📞 client',
|
||||
!backupMode
|
||||
? {
|
||||
workspace: '🧲 ' + workspace
|
||||
}
|
||||
: {}
|
||||
)
|
||||
const userCtx = requestCtx.newChild('📞 client', {})
|
||||
|
||||
// Calculate total number of clients
|
||||
const reqId = generateId()
|
||||
|
||||
const st = Date.now()
|
||||
return userCtx
|
||||
.with(`🧭 ${backupMode ? 'handleBackup' : 'handleRequest'}`, {}, async (ctx) => {
|
||||
.with('🧭 handleRequest', {}, async (ctx) => {
|
||||
if (request.time != null) {
|
||||
const delta = Date.now() - request.time
|
||||
requestCtx.measure('msg-receive-delta', delta)
|
||||
}
|
||||
const wsRef = this.workspaces.get(workspace)
|
||||
if (wsRef === undefined) {
|
||||
if (service.workspace.closing !== undefined) {
|
||||
ws.send(
|
||||
ctx,
|
||||
{
|
||||
id: request.id,
|
||||
error: unknownError('No workspace')
|
||||
error: unknownError('Workspace is closing')
|
||||
},
|
||||
service.binaryMode,
|
||||
service.useCompression
|
||||
)
|
||||
return
|
||||
}
|
||||
if (request.method === 'forceClose') {
|
||||
if (request.id === -1 && request.method === 'hello') {
|
||||
await this.handleHello<S>(request, service, ctx, workspace, ws, requestCtx)
|
||||
return
|
||||
}
|
||||
if (request.id === -2 && request.method === 'forceClose') {
|
||||
let done = false
|
||||
if (wsRef.upgrade) {
|
||||
const wsRef = this.workspaces.get(workspace)
|
||||
if (wsRef?.upgrade ?? false) {
|
||||
done = true
|
||||
this.ctx.warn('FORCE CLOSE', { workspace })
|
||||
// In case of upgrade, we need to force close workspace not in interval handler
|
||||
@ -985,62 +1009,6 @@ class TSessionManager implements SessionManager {
|
||||
ws.send(ctx, forceCloseResponse, service.binaryMode, service.useCompression)
|
||||
return
|
||||
}
|
||||
if (request.id === -1 && request.method === 'hello') {
|
||||
const hello = request as HelloRequest
|
||||
service.binaryMode = hello.binary ?? false
|
||||
service.useCompression = hello.compression ?? false
|
||||
|
||||
if (LOGGING_ENABLED) {
|
||||
ctx.info('hello happen', {
|
||||
workspace,
|
||||
user: service.getUser(),
|
||||
binary: service.binaryMode,
|
||||
compression: service.useCompression,
|
||||
timeToHello: Date.now() - service.createTime,
|
||||
workspaceUsers: this.workspaces.get(workspace)?.sessions?.size,
|
||||
totalUsers: this.sessions.size
|
||||
})
|
||||
}
|
||||
const reconnect = this.reconnectIds.has(service.sessionId)
|
||||
if (reconnect) {
|
||||
this.reconnectIds.delete(service.sessionId)
|
||||
}
|
||||
const helloResponse: HelloResponse = {
|
||||
id: -1,
|
||||
result: 'hello',
|
||||
binary: service.binaryMode,
|
||||
reconnect,
|
||||
serverVersion: this.serverVersion
|
||||
}
|
||||
ws.send(requestCtx, helloResponse, false, false)
|
||||
return
|
||||
}
|
||||
const opContext = (ctx: MeasureContext): ClientSessionCtx => ({
|
||||
sendResponse: async (msg) => {
|
||||
await sendResponse(requestCtx, service, ws, {
|
||||
id: request.id,
|
||||
result: msg,
|
||||
time: Date.now() - st,
|
||||
bfst: Date.now(),
|
||||
queue: service.requests.size
|
||||
})
|
||||
userCtx.end()
|
||||
},
|
||||
sendPong: () => {
|
||||
ws.sendPong()
|
||||
},
|
||||
ctx,
|
||||
sendError: async (msg, error: Status) => {
|
||||
await sendResponse(ctx, service, ws, {
|
||||
id: request.id,
|
||||
result: msg,
|
||||
error,
|
||||
time: Date.now() - st,
|
||||
bfst: Date.now(),
|
||||
queue: service.requests.size
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
service.requests.set(reqId, {
|
||||
id: reqId,
|
||||
@ -1052,11 +1020,16 @@ class TSessionManager implements SessionManager {
|
||||
return
|
||||
}
|
||||
|
||||
const pipeline =
|
||||
service.workspace.pipeline instanceof Promise ? await service.workspace.pipeline : service.workspace.pipeline
|
||||
|
||||
const f = (service as any)[request.method]
|
||||
try {
|
||||
const params = [...request.params]
|
||||
|
||||
await ctx.with('🧨 process', {}, (callTx) => f.apply(service, [opContext(callTx), ...params]))
|
||||
await ctx.with('🧨 process', {}, (callTx) =>
|
||||
f.apply(service, [this.createOpContext(callTx, pipeline, request, service, ws), ...params])
|
||||
)
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
if (LOGGING_ENABLED) {
|
||||
@ -1079,16 +1052,52 @@ class TSessionManager implements SessionManager {
|
||||
service.requests.delete(reqId)
|
||||
})
|
||||
}
|
||||
|
||||
private async handleHello<S extends Session>(
|
||||
request: Request<any>,
|
||||
service: S,
|
||||
ctx: MeasureContext<any>,
|
||||
workspace: string,
|
||||
ws: ConnectionSocket,
|
||||
requestCtx: MeasureContext<any>
|
||||
): Promise<void> {
|
||||
const hello = request as HelloRequest
|
||||
service.binaryMode = hello.binary ?? false
|
||||
service.useCompression = hello.compression ?? false
|
||||
|
||||
if (LOGGING_ENABLED) {
|
||||
ctx.info('hello happen', {
|
||||
workspace,
|
||||
user: service.getUser(),
|
||||
binary: service.binaryMode,
|
||||
compression: service.useCompression,
|
||||
timeToHello: Date.now() - service.createTime,
|
||||
workspaceUsers: this.workspaces.get(workspace)?.sessions?.size,
|
||||
totalUsers: this.sessions.size
|
||||
})
|
||||
}
|
||||
const reconnect = this.reconnectIds.has(service.sessionId)
|
||||
if (reconnect) {
|
||||
this.reconnectIds.delete(service.sessionId)
|
||||
}
|
||||
const pipeline =
|
||||
service.workspace.pipeline instanceof Promise ? await service.workspace.pipeline : service.workspace.pipeline
|
||||
const helloResponse: HelloResponse = {
|
||||
id: -1,
|
||||
result: 'hello',
|
||||
binary: service.binaryMode,
|
||||
reconnect,
|
||||
serverVersion: this.serverVersion,
|
||||
lastTx: pipeline.context.lastTx,
|
||||
lastHash: pipeline.context.lastHash
|
||||
}
|
||||
ws.send(requestCtx, helloResponse, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionManager (
|
||||
ctx: MeasureContext,
|
||||
sessionFactory: (
|
||||
token: Token,
|
||||
pipeline: Pipeline,
|
||||
workspaceId: WorkspaceIdWithUrl,
|
||||
branding: Branding | null
|
||||
) => Session,
|
||||
sessionFactory: (token: Token, workspace: Workspace) => Session,
|
||||
brandingMap: BrandingMap,
|
||||
timeouts: Timeouts,
|
||||
profiling:
|
||||
@ -1110,12 +1119,7 @@ export function startSessionManager (
|
||||
opt: {
|
||||
port: number
|
||||
pipelineFactory: PipelineFactory
|
||||
sessionFactory: (
|
||||
token: Token,
|
||||
pipeline: Pipeline,
|
||||
workspaceId: WorkspaceIdWithUrl,
|
||||
branding: Branding | null
|
||||
) => Session
|
||||
sessionFactory: (token: Token, workspace: Workspace) => Session
|
||||
brandingMap: BrandingMap
|
||||
serverFactory: ServerFactory
|
||||
enableCompression?: boolean
|
||||
|
@ -258,12 +258,12 @@ export async function upgradeWorkspaceWith (
|
||||
systemAccountEmail,
|
||||
'backup',
|
||||
true,
|
||||
{ targets: {}, txes: [] },
|
||||
undefined,
|
||||
wsUrl,
|
||||
null,
|
||||
true,
|
||||
new Map(),
|
||||
new Map(),
|
||||
undefined,
|
||||
undefined,
|
||||
pipeline.context.modelDb
|
||||
)
|
||||
ctx.contextData = contextData
|
||||
|
@ -39,8 +39,8 @@ import {
|
||||
type Tx,
|
||||
type TxResult
|
||||
} from '@hcengineering/core'
|
||||
import { createDummyStorageAdapter } from '@hcengineering/server-core'
|
||||
import { ClientSession, startSessionManager } from '@hcengineering/server'
|
||||
import { createDummyStorageAdapter } from '@hcengineering/server-core'
|
||||
import { startHttpServer } from '../server_http'
|
||||
import { genMinModel } from './minmodel'
|
||||
|
||||
@ -94,8 +94,7 @@ describe('server', () => {
|
||||
loadModel: async (ctx, lastModelTx, hash) => []
|
||||
}
|
||||
},
|
||||
sessionFactory: (token, pipeline, workspaceId, branding) =>
|
||||
new ClientSession(token, pipeline, workspaceId, branding, true),
|
||||
sessionFactory: (token, workspace) => new ClientSession(token, workspace, true),
|
||||
port: 3335,
|
||||
brandingMap: {},
|
||||
serverFactory: startHttpServer,
|
||||
@ -206,8 +205,7 @@ describe('server', () => {
|
||||
loadModel: async (ctx, lastModelTx, hash) => []
|
||||
}
|
||||
},
|
||||
sessionFactory: (token, pipeline, workspaceId, branding) =>
|
||||
new ClientSession(token, pipeline, workspaceId, branding, true),
|
||||
sessionFactory: (token, workspace) => new ClientSession(token, workspace, true),
|
||||
port: 3336,
|
||||
brandingMap: {},
|
||||
serverFactory: startHttpServer,
|
||||
|
@ -2,10 +2,11 @@
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
|
||||
import { Branding, TxOperations, WorkspaceIdWithUrl } from '@hcengineering/core'
|
||||
import { Branding, generateId, TxOperations, WorkspaceIdWithUrl } from '@hcengineering/core'
|
||||
import { MarkupMarkType, MarkupNode, MarkupNodeType, traverseMarkupNode } from '@hcengineering/text'
|
||||
import { getPublicLink } from '@hcengineering/server-guest-resources'
|
||||
import { Task } from '@hcengineering/task'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
|
||||
const githubLinkText = process.env.LINK_TEXT ?? 'Huly®:'
|
||||
|
||||
@ -54,6 +55,7 @@ export async function appendGuestLink (
|
||||
const publicLink = await getPublicLink(doc, client, workspace, false, branding)
|
||||
await stripGuestLink(markdown)
|
||||
appendGuestLinkToModel(markdown, publicLink, doc.identifier)
|
||||
appendGuestLinkToImage(markdown, workspace)
|
||||
}
|
||||
|
||||
export function appendGuestLinkToModel (markdown: MarkupNode, publicLink: string, identifier: string): void {
|
||||
@ -76,3 +78,31 @@ export function appendGuestLinkToModel (markdown: MarkupNode, publicLink: string
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function findImageTags (node: MarkupNode): MarkupNode[] {
|
||||
if (node.type === MarkupNodeType.paragraph) {
|
||||
return node.content?.flatMap(findImageTags) ?? []
|
||||
}
|
||||
if (node.type === MarkupNodeType.image) {
|
||||
return [node]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function appendGuestLinkToImage (markdown: MarkupNode, workspace: WorkspaceIdWithUrl): void {
|
||||
const imageTags: MarkupNode[] = markdown.content?.flatMap(findImageTags) ?? []
|
||||
|
||||
if (imageTags.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = generateId()
|
||||
const token = generateToken(id, workspace, { linkId: id, guest: 'true' })
|
||||
|
||||
for (const imageTag of imageTags) {
|
||||
const src = imageTag.attrs?.src
|
||||
if (src !== undefined && imageTag.attrs !== undefined) {
|
||||
imageTag.attrs.token = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -191,7 +191,7 @@ export class GithubWorker implements IntegrationManager {
|
||||
text ?? '',
|
||||
concatLink(this.getBranding()?.front ?? config.FrontURL, `/browse/?workspace=${this.workspace.name}`),
|
||||
// TODO storage URL
|
||||
concatLink(this.getBranding()?.front ?? config.FrontURL, `/files?workspace=${this.workspace.name}&file=`),
|
||||
concatLink(this.getBranding()?.front ?? config.FrontURL, `/files/${this.workspace.name}/`),
|
||||
preprocessor
|
||||
)
|
||||
}
|
||||
|
@ -61,7 +61,6 @@
|
||||
"aws-sdk": "^2.1423.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "~16.0.0",
|
||||
"express": "^4.21.2",
|
||||
"got": "^11.8.3"
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
|
@ -6,13 +6,13 @@ import { convertEstimation } from '../../tracker/tracker.utils'
|
||||
export class TemplateDetailsPage extends CommonTrackerPage {
|
||||
inputTitle = (): Locator => this.page.locator('div.popupPanel-body input[type="text"]')
|
||||
inputDescription = (): Locator => this.page.locator('div.popupPanel-body div.textInput p')
|
||||
buttonPriority = (): Locator => this.page.locator('//span[text()="Priority"]/../button[1]//span')
|
||||
buttonAssignee = (): Locator => this.page.locator('(//span[text()="Assignee"]/../div/button)[1]')
|
||||
buttonPriority = (): Locator => this.page.locator('//span[text()="Priority"]/following-sibling::button[1]//span')
|
||||
buttonAssignee = (): Locator => this.page.locator('//span[text()="Assignee"]/following-sibling::div[1]/button/span')
|
||||
textLabels = (dataLabels: string): Locator => this.page.locator('div.menu-group span', { hasText: dataLabels })
|
||||
buttonAddLabel = (): Locator => this.page.locator('//span[text()="Labels"]/../button[2]//span')
|
||||
buttonComponent = (): Locator => this.page.locator('//span[text()="Component"]/../div/div/button')
|
||||
buttonEstimation = (): Locator => this.page.locator('(//span[text()="Estimation"]/../div/button)[3]')
|
||||
buttonDueDate = (): Locator => this.page.locator('(//span[text()="Due date"]/../div/button)[2]')
|
||||
buttonAddLabel = (): Locator => this.page.locator('//span[text()="Labels"]/following-sibling::button[1]//span')
|
||||
buttonComponent = (): Locator => this.page.locator('//span[text()="Component"]/following-sibling::div[1]/div/button')
|
||||
buttonEstimation = (): Locator => this.page.locator('//span[text()="Estimation"]/following-sibling::div[1]/button')
|
||||
buttonDueDate = (): Locator => this.page.locator('//span[text()="Due date"]/following-sibling::div[1]/button')
|
||||
buttonSaveDueDate = (): Locator => this.page.locator('div.footer > button')
|
||||
activityContent = (): Locator => this.page.locator('div.grid div.content')
|
||||
buttonDelete = (): Locator => this.page.locator('button[class*="menuItem"] > span', { hasText: 'Delete' })
|
||||
|
@ -1,7 +1,6 @@
|
||||
// Copyright © 2024 Huly Labs.
|
||||
|
||||
import {
|
||||
Branding,
|
||||
generateId,
|
||||
type Class,
|
||||
type Doc,
|
||||
@ -9,20 +8,17 @@ import {
|
||||
type FindOptions,
|
||||
type MeasureContext,
|
||||
type Ref,
|
||||
type Tx,
|
||||
type WorkspaceIdWithUrl
|
||||
type Tx
|
||||
} from '@hcengineering/core'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import { RPCHandler } from '@hcengineering/rpc'
|
||||
import { ClientSession, createSessionManager, doSessionOp, type WebsocketData } from '@hcengineering/server'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import {
|
||||
ClientSessionCtx,
|
||||
createDummyStorageAdapter,
|
||||
initStatisticsContext,
|
||||
loadBrandingMap,
|
||||
pingConst,
|
||||
Pipeline,
|
||||
pongConst,
|
||||
Session,
|
||||
type ConnectionSocket,
|
||||
@ -108,8 +104,7 @@ export class Transactor extends DurableObject<Env> {
|
||||
|
||||
this.sessionManager = createSessionManager(
|
||||
this.measureCtx,
|
||||
(token: Token, pipeline: Pipeline, workspaceId: WorkspaceIdWithUrl, branding: Branding | null) =>
|
||||
new ClientSession(token, pipeline, workspaceId, branding, false),
|
||||
(token: Token, workspace) => new ClientSession(token, workspace, false),
|
||||
loadBrandingMap(), // TODO: Support branding map
|
||||
{
|
||||
pingTimeout: 10000,
|
||||
@ -383,7 +378,9 @@ export class Transactor extends DurableObject<Env> {
|
||||
const cs = this.createDummyClientSocket()
|
||||
try {
|
||||
const session = await this.makeRpcSession(rawToken, cs)
|
||||
result = await session.findAllRaw(this.measureCtx, _class, query ?? {}, options ?? {})
|
||||
const pipeline =
|
||||
session.workspace.pipeline instanceof Promise ? await session.workspace.pipeline : session.workspace.pipeline
|
||||
result = await pipeline.findAll(this.measureCtx, _class, query ?? {}, options ?? {})
|
||||
} catch (error: any) {
|
||||
result = { error: `${error}` }
|
||||
} finally {
|
||||
@ -397,21 +394,9 @@ export class Transactor extends DurableObject<Env> {
|
||||
const cs = this.createDummyClientSocket()
|
||||
try {
|
||||
const session = await this.makeRpcSession(rawToken, cs)
|
||||
const sessionCtx: ClientSessionCtx = {
|
||||
ctx: this.measureCtx,
|
||||
sendResponse: async (msg) => {
|
||||
result = msg
|
||||
},
|
||||
// TODO: Inedeed, the pipeline doesn't return errors,
|
||||
// it just logs them to console and return an empty result
|
||||
sendError: async (msg, error) => {
|
||||
result = { error: `${msg}`, status: `${error}` }
|
||||
},
|
||||
sendPong: () => {
|
||||
cs.sendPong()
|
||||
}
|
||||
}
|
||||
await session.tx(sessionCtx, tx)
|
||||
const pipeline =
|
||||
session.workspace.pipeline instanceof Promise ? await session.workspace.pipeline : session.workspace.pipeline
|
||||
await pipeline.tx(this.measureCtx, [tx])
|
||||
} catch (error: any) {
|
||||
result = { error: `${error}` }
|
||||
} finally {
|
||||
@ -420,35 +405,43 @@ export class Transactor extends DurableObject<Env> {
|
||||
return result
|
||||
}
|
||||
|
||||
async getModel (): Promise<any> {
|
||||
async getModel (rawToken: string): Promise<any> {
|
||||
let result: Tx[] = []
|
||||
const cs = this.createDummyClientSocket()
|
||||
try {
|
||||
const session = await this.makeRpcSession(rawToken, cs)
|
||||
const pipeline =
|
||||
session.workspace.pipeline instanceof Promise ? await session.workspace.pipeline : session.workspace.pipeline
|
||||
const ret = await pipeline.loadModel(this.measureCtx, 0)
|
||||
if (Array.isArray(ret)) {
|
||||
result = ret
|
||||
} else {
|
||||
result = ret.transactions
|
||||
}
|
||||
} catch (error: any) {
|
||||
result = []
|
||||
} finally {
|
||||
await this.sessionManager.close(this.measureCtx, cs, this.workspace)
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const buffer = encoder.encode(JSON.stringify(model))
|
||||
const buffer = encoder.encode(JSON.stringify(result))
|
||||
const gzipAsync = promisify(gzip)
|
||||
const compressed = await gzipAsync(buffer)
|
||||
return compressed
|
||||
}
|
||||
|
||||
async getAccount (rawToken: string, workspaceId: string, tx: Tx): Promise<any> {
|
||||
let result
|
||||
async getAccount (rawToken: string): Promise<any> {
|
||||
const cs = this.createDummyClientSocket()
|
||||
try {
|
||||
const session = await this.makeRpcSession(rawToken, cs)
|
||||
const sessionCtx: ClientSessionCtx = {
|
||||
ctx: this.measureCtx,
|
||||
sendResponse: async (msg) => {
|
||||
result = msg
|
||||
},
|
||||
sendError: async (msg, error) => {
|
||||
result = { error: `${msg}`, status: `${error}` }
|
||||
},
|
||||
sendPong: () => {}
|
||||
}
|
||||
await (session as any).getAccount(sessionCtx)
|
||||
const pipeline =
|
||||
session.workspace.pipeline instanceof Promise ? await session.workspace.pipeline : session.workspace.pipeline
|
||||
return session.getRawAccount(pipeline)
|
||||
} catch (error: any) {
|
||||
result = { error: `${error}` }
|
||||
return { error: `${error}` }
|
||||
} finally {
|
||||
await this.sessionManager.close(this.measureCtx, cs, this.workspace)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user