From b84b10924687798f871b22d04bafd44347a83fff Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sun, 18 Oct 2026 06:35:58 +0000 Subject: [PATCH] fix(setting): do not query domainless mixins when checking attribute usage Opening or right-clicking an attribute in class settings checked whether it is in use with client.findOne(attribute.attributeOf, ...). For mixins declared on a class without a domain, such as notification:mixin:Collaborators (a mixin on core.class.Doc), the query throws 'domain not found', so the edit panel and the context menu never opened. Move the check into an isAttributeUsed helper that skips the query when the attribute class has no domain and treats the attribute as used, since such a mixin can be applied to documents in any domain. This keeps the type locked and shows the stronger delete confirmation. Add unit tests for the helper. Fixes #7142 Signed-off-by: Michael Uray --- .../src/__tests__/attributeUsage.test.ts | 48 +++++++++++++++++++ .../setting-resources/src/attributeUsage.ts | 34 +++++++++++++ .../src/components/ClassAttributes.svelte | 3 +- .../src/components/ClassAttributesList.svelte | 3 +- 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 plugins/setting-resources/src/__tests__/attributeUsage.test.ts create mode 100644 plugins/setting-resources/src/attributeUsage.ts diff --git a/plugins/setting-resources/src/__tests__/attributeUsage.test.ts b/plugins/setting-resources/src/__tests__/attributeUsage.test.ts new file mode 100644 index 00000000000..b392d70d783 --- /dev/null +++ b/plugins/setting-resources/src/__tests__/attributeUsage.test.ts @@ -0,0 +1,48 @@ +// +// 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 { AnyAttribute, Class, Doc, Domain, Ref } from '@hcengineering/core' +import { isAttributeUsed } from '../attributeUsage' + +function makeClient (domain: Domain | undefined, found: Doc | undefined): any { + return { + getHierarchy: () => ({ findDomain: jest.fn(() => domain) }), + findOne: jest.fn(async () => found) + } +} + +const attribute: Pick = { + attributeOf: 'test:mixin:Custom' as Ref>, + name: 'custom1' +} + +describe('isAttributeUsed', () => { + it('queries the attribute class when it has a domain', async () => { + const client = makeClient('task' as Domain, { _id: 'doc1' } as unknown as Doc) + expect(await isAttributeUsed(client, attribute)).toBe(true) + expect(client.findOne).toHaveBeenCalledWith(attribute.attributeOf, { custom1: { $exists: true } }) + }) + + it('returns false when no document has the attribute', async () => { + const client = makeClient('task' as Domain, undefined) + expect(await isAttributeUsed(client, attribute)).toBe(false) + }) + + it('does not query a domainless class and treats the attribute as used', async () => { + const client = makeClient(undefined, undefined) + expect(await isAttributeUsed(client, attribute)).toBe(true) + expect(client.findOne).not.toHaveBeenCalled() + }) +}) diff --git a/plugins/setting-resources/src/attributeUsage.ts b/plugins/setting-resources/src/attributeUsage.ts new file mode 100644 index 00000000000..ed5dcf00dcb --- /dev/null +++ b/plugins/setting-resources/src/attributeUsage.ts @@ -0,0 +1,34 @@ +// +// 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 { AnyAttribute, Client } from '@hcengineering/core' + +/** + * Check whether any document has a value for the attribute. + * + * Mixins declared on a domainless class (e.g. a mixin on core.class.Doc) can be + * applied to documents of any domain, so they cannot be queried directly. The + * attribute is treated as used in that case, which keeps its type locked and + * shows the stronger delete confirmation. + */ +export async function isAttributeUsed ( + client: Pick, + attribute: Pick +): Promise { + if (client.getHierarchy().findDomain(attribute.attributeOf) === undefined) { + return true + } + return (await client.findOne(attribute.attributeOf, { [attribute.name]: { $exists: true } })) !== undefined +} diff --git a/plugins/setting-resources/src/components/ClassAttributes.svelte b/plugins/setting-resources/src/components/ClassAttributes.svelte index b4be28df36a..0eb6a456b73 100644 --- a/plugins/setting-resources/src/components/ClassAttributes.svelte +++ b/plugins/setting-resources/src/components/ClassAttributes.svelte @@ -33,6 +33,7 @@ import { ObjectPresenter } from '@hcengineering/view-resources' import { onDestroy } from 'svelte' import settings from '../plugin' + import { isAttributeUsed } from '../attributeUsage' import { clearSettingsStore, settingsStore } from '../store' import ClassAttributesList from './ClassAttributesList.svelte' import CreateAttribute from './CreateAttribute.svelte' @@ -147,7 +148,7 @@ const handleSelect = async (event: CustomEvent): Promise => { selected = event.detail as AnyAttribute if (selected?._id != null) { - const exist = (await client.findOne(selected.attributeOf, { [selected.name]: { $exists: true } })) !== undefined + const exist = await isAttributeUsed(client, selected) $settingsStore = { id: selected._id, component: EditAttribute, diff --git a/plugins/setting-resources/src/components/ClassAttributesList.svelte b/plugins/setting-resources/src/components/ClassAttributesList.svelte index e65ab2cfc9a..0d6870f00ac 100644 --- a/plugins/setting-resources/src/components/ClassAttributesList.svelte +++ b/plugins/setting-resources/src/components/ClassAttributesList.svelte @@ -40,6 +40,7 @@ } from '@hcengineering/ui' import { getContextActions, SortableList } from '@hcengineering/view-resources' import settings from '../plugin' + import { isAttributeUsed } from '../attributeUsage' import ClassAttributeRow from './ClassAttributeRow.svelte' import { makeRank } from '@hcengineering/rank' import EditAttribute from './EditAttribute.svelte' @@ -133,7 +134,7 @@ async function showMenu (ev: MouseEvent, attribute: AnyAttribute): Promise { hovered = attribute._id - const exist = (await client.findOne(attribute.attributeOf, { [attribute.name]: { $exists: true } })) !== undefined + const exist = await isAttributeUsed(client, attribute) const actions: Action[] = [ { label: presentation.string.Edit,