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,