Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions plugins/view-resources/src/components/filter/FilterBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentQuery, Ref, Space, getCurrentAccount } from '@hcengineering/core'
import { AccountRole, Class, Doc, DocumentQuery, Ref, Space, getCurrentAccount } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { getClient, reduceCalls } from '@hcengineering/presentation'
import { Button, IconAdd, eventToHTMLElement, getCurrentLocation, showPopup } from '@hcengineering/ui'
Expand All @@ -37,6 +37,9 @@
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()

const account = getCurrentAccount()
const canSaveFilteredView = account.role !== AccountRole.ReadOnlyGuest && account.role !== AccountRole.DocGuest

function onChange (e: Filter | undefined) {
if (e === undefined) return
updateFilter(e)
Expand Down Expand Up @@ -189,7 +192,7 @@
</div>
</div>

{#if !hideSaveButtons}
{#if !hideSaveButtons && canSaveFilteredView}
<div class="flex gap-1-5">
<Button
icon={view.icon.Views}
Expand Down
11 changes: 9 additions & 2 deletions plugins/view-resources/src/components/filter/FilterSave.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import core, { Class, Doc, Ref, getCurrentAccount } from '@hcengineering/core'
import core, { Class, Doc, Ref, Space, getCurrentAccount } from '@hcengineering/core'
import { Card, getClient } from '@hcengineering/presentation'
import { Button, EditBox, ToggleWithLabel, getCurrentResolvedLocation } from '@hcengineering/ui'
import { ViewOptions } from '@hcengineering/view'
Expand All @@ -16,11 +16,18 @@
let filterName = ''
const client = getClient()

async function getTargetSpace (spaceRef: string | undefined): Promise<Ref<Space>> {
// Store the filtered view in the space it is built on
if (spaceRef === undefined) return core.space.Workspace
const space = await client.findOne(core.class.Space, { _id: spaceRef as Ref<Space> })
return space?._id ?? core.space.Workspace
}

async function saveFilter () {
const loc = getCurrentResolvedLocation()
loc.fragment = undefined
const filters = JSON.stringify($filterStore)
await client.createDoc(view.class.FilteredView, core.space.Workspace, {
await client.createDoc(view.class.FilteredView, await getTargetSpace(loc.path[3]), {
name: filterName,
location: loc,
filterClass: _class,
Expand Down
5 changes: 3 additions & 2 deletions plugins/workbench-resources/src/components/SavedView.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import contact from '@hcengineering/contact'
import { Ref, getCurrentAccount, toIdMap } from '@hcengineering/core'
import { AccountRole, Ref, getCurrentAccount, toIdMap } from '@hcengineering/core'
import { copyTextToClipboard, createQuery, getClient } from '@hcengineering/presentation'
import setting from '@hcengineering/setting'
import {
Expand Down Expand Up @@ -42,11 +42,12 @@
const dispatch = createEventDispatcher()
const client = getClient()
const myAcc = getCurrentAccount()
const canUseFilteredViews = myAcc.role !== AccountRole.ReadOnlyGuest && myAcc.role !== AccountRole.DocGuest

const filteredViewsQuery = createQuery()
let availableFilteredViews: FilteredView[] = []
let myFilteredViews: FilteredView[] = []
$: if (alias !== undefined) {
$: if (alias !== undefined && canUseFilteredViews) {
filteredViewsQuery.query<FilteredView>(view.class.FilteredView, { attachedTo: alias }, (result) => {
myFilteredViews = result.filter((p) => p.users.includes(myAcc.uuid))
availableFilteredViews = result.filter((p) => p.sharable && !p.users.includes(myAcc.uuid))
Expand Down
276 changes: 276 additions & 0 deletions services/export/pod-export/src/__tests__/converter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import {
type Class,
type Client,
type Doc,
type Hierarchy,
type MarkupBlobRef,
type MeasureContext,
type Ref,
type WorkspaceIds
} from '@hcengineering/core'
import { type StorageAdapter } from '@hcengineering/server-core'
import { Buffer } from 'buffer'
import { UnifiedConverter } from '../converter'

// isId requires a 24-character hex string
const refA = '6763a1b2c3d4e5f601234567' as Ref<Doc>
const refB = '6763a1b2c3d4e5f601234568' as Ref<Doc>
const targetClass = 'test:class:Target' as Ref<Class<Doc>>
const abstractClass = 'core:class:Doc' as Ref<Class<Doc>>

function createMockMeasureContext (): MeasureContext {
return {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
newChild: jest.fn(),
with: jest.fn(),
withSync: jest.fn(),
measure: jest.fn(),
end: jest.fn()
} as unknown as MeasureContext
}

function createMockHierarchy (): Hierarchy {
return {
getAllAttributes: jest.fn(() => new Map()),
isMixin: jest.fn(() => false),
hasMixin: jest.fn(() => false),
isDerived: jest.fn(() => true),
getBaseClass: jest.fn((c: any) => c),
findDomain: jest.fn(() => 'test-domain'),
getClass: jest.fn(),
findAttribute: jest.fn()
} as unknown as Hierarchy
}

function createMockClient (hierarchy: Hierarchy): Client {
return {
findAll: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
getHierarchy: jest.fn(() => hierarchy)
} as unknown as Client
}

function createMockStorageAdapter (): StorageAdapter {
return {
read: jest.fn(),
stat: jest.fn(),
put: jest.fn(),
remove: jest.fn(),
exists: jest.fn()
} as unknown as StorageAdapter
}

describe('UnifiedConverter', () => {
let context: MeasureContext
let hierarchy: Hierarchy
let client: Client
let storage: StorageAdapter
let wsIds: WorkspaceIds
let converter: UnifiedConverter

beforeEach(() => {
jest.clearAllMocks()
context = createMockMeasureContext()
hierarchy = createMockHierarchy()
client = createMockClient(hierarchy)
storage = createMockStorageAdapter()
wsIds = { uuid: 'test-ws' as any, url: 'ws://test' }
converter = new UnifiedConverter(context, client, storage, wsIds)
})

describe('resolveReference', () => {
it('should return raw ref for abstract classes without domain instead of querying', async () => {
;(hierarchy.findDomain as jest.Mock).mockReturnValue(undefined)

const result = await (converter as any).resolveReference(refA, abstractClass)

expect(result).toBe(refA)
// Must not attempt findAll: abstract classes have no domain and the query would fail
expect(client.findAll).not.toHaveBeenCalled()
expect(context.error).not.toHaveBeenCalled()
expect(context.warn).not.toHaveBeenCalled()
})

it('should resolve a reference to a meaningful identifier', async () => {
const doc = { _id: refA, _class: targetClass, identifier: 'TSK-1' }
;(client.findAll as jest.Mock)
.mockResolvedValueOnce([{ _id: refA }]) // ids query
.mockResolvedValueOnce([doc]) // batch query

const result = await (converter as any).resolveReference(refA, targetClass)

expect(result).toBe('TSK-1')
})

it('should warn only once per missing reference', async () => {
;(client.findAll as jest.Mock).mockResolvedValue([])

const first = await (converter as any).resolveReference(refA, targetClass)
const second = await (converter as any).resolveReference(refA, targetClass)

expect(first).toBe(refA)
expect(second).toBe(refA)
const warns = (context.warn as jest.Mock).mock.calls.filter((c) => String(c[0]).includes(refA))
expect(warns).toHaveLength(1)
})

it('should not retry cache loading after a failure and should log the failure once', async () => {
;(client.findAll as jest.Mock).mockRejectedValue(new Error('domain not found: core:class:Doc '))

const first = await (converter as any).resolveReference(refA, targetClass)
const second = await (converter as any).resolveReference(refB, targetClass)

// References are kept as-is, export continues
expect(first).toBe(refA)
expect(second).toBe(refB)

// The failed query must not be retried for every reference
expect(client.findAll).toHaveBeenCalledTimes(1)

// Cache load failure is logged once
const cacheErrors = (context.error as jest.Mock).mock.calls.filter((c) =>
String(c[0]).includes('Failed to load document cache')
)
expect(cacheErrors).toHaveLength(1)

// No 'Failed to resolve reference' spam per each reference
const refErrors = (context.error as jest.Mock).mock.calls.filter((c) =>
String(c[0]).includes('Failed to resolve reference')
)
expect(refErrors).toHaveLength(0)
})

it('should return non-id values as-is', async () => {
const result = await (converter as any).resolveReference('not-an-id', targetClass)

expect(result).toBe('not-an-id')
expect(client.findAll).not.toHaveBeenCalled()
})
})

describe('resolveMarkdown', () => {
const blobRef = 'blob-description-1' as MarkupBlobRef

it('should return markup content when blob exists', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockResolvedValue([Buffer.from('test')])

const result = await (converter as any).resolveMarkdown(blobRef)

expect(result).toBe('test')
expect(context.warn).not.toHaveBeenCalled()
expect(context.error).not.toHaveBeenCalled()
})

it('should warn and return empty string when blob does not exist (stat undefined)', async () => {
;(storage.stat as jest.Mock).mockResolvedValue(undefined)

const result = await (converter as any).resolveMarkdown(blobRef)

expect(result).toBe('')
expect(storage.read).not.toHaveBeenCalled()
expect(context.warn).toHaveBeenCalledWith(expect.stringContaining(`Blob not found: ${blobRef}`))
expect(context.error).not.toHaveBeenCalled()
})

it('should treat "missing" read errors as warnings, not export errors', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockRejectedValue(new Error(`uuid=x dataId=y missing ${blobRef}`))

const result = await (converter as any).resolveMarkdown(blobRef)

expect(result).toBe('')
expect(context.warn).toHaveBeenCalledWith(expect.stringContaining(`Blob content not found: ${blobRef}`))
expect(context.error).not.toHaveBeenCalled()
})

it('should warn only once per missing blob', async () => {
;(storage.stat as jest.Mock).mockResolvedValue(undefined)

await (converter as any).resolveMarkdown(blobRef)
await (converter as any).resolveMarkdown(blobRef)

const warns = (context.warn as jest.Mock).mock.calls.filter((c) => String(c[0]).includes(blobRef))
expect(warns).toHaveLength(1)
})

it('should log unexpected read errors as errors and return empty string', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockRejectedValue(new Error('connection refused'))

const result = await (converter as any).resolveMarkdown(blobRef)

expect(result).toBe('')
expect(context.error).toHaveBeenCalledWith(
expect.stringContaining(`Failed to resolve markup content: ${blobRef}`),
expect.objectContaining({ error: 'connection refused' })
)
})
})

describe('resolveAttachments', () => {
const docId = refA
const docClass = 'test:class:Issue' as Ref<Class<Doc>>

function mockAttachmentCache (): void {
const att = {
_id: refB,
_class: 'attachment:class:Attachment' as Ref<Class<Doc>>,
attachedTo: docId,
attachedToClass: docClass,
collection: 'attachments',
name: 'file.txt',
size: 4,
type: 'text/plain',
file: 'blob-file-1'
}
;(client.findAll as jest.Mock)
.mockResolvedValueOnce([{ _id: att._id }]) // ids query
.mockResolvedValueOnce([att]) // batch query
}

it('should return empty buffer and warn when attachment read fails', async () => {
mockAttachmentCache()
;(storage.read as jest.Mock).mockRejectedValue(new Error('missing blob-file-1'))

const attachments = await (converter as any).resolveAttachments(docId, docClass)

expect(attachments).toHaveLength(1)
const data = await attachments[0].getData()

expect(data).toEqual(Buffer.from([]))
expect(context.warn).toHaveBeenCalledWith(
expect.stringContaining(`Failed to read attachment: ${refB}`),
expect.objectContaining({ error: 'missing blob-file-1' })
)
})

it('should return attachment data when read succeeds', async () => {
mockAttachmentCache()
;(storage.read as jest.Mock).mockResolvedValue([Buffer.from('test')])

const attachments = await (converter as any).resolveAttachments(docId, docClass)
const data = await attachments[0].getData()

expect(data).toEqual(Buffer.from('test'))
expect(context.warn).not.toHaveBeenCalled()
})
})
})
Loading
Loading