From 513a186ab31df02eae6e6eeb73df0b252dfb7664 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sun, 18 Oct 2026 00:35:38 +0000 Subject: [PATCH] fix(view): show the selected enum label as tooltip and keep tooltips in sync The editable enum presenter used in card tables built its tooltip from the raw attribute value, while the dropdown shows "Not selected" whenever that value is not one of the enum values. The tooltip action also ignored option changes when it was mounted without a tooltip, and after the tooltip was removed it kept the stale options (or failed on hover), so the cell could show the label of another value. The presenter now derives the tooltip from the selected enum item, so an empty or unknown value has no tooltip. The tooltip action always registers its handlers, ignores hovers while no label/component is set, and closes its tooltip when the options are cleared, so reactive updates in both directions take effect. Fixes #8673 Signed-off-by: Michael Uray --- packages/ui/src/__test__/tooltips.test.ts | 117 ++++++++++++++++++ packages/ui/src/tooltips.ts | 18 ++- .../src/components/EnumPresenter.svelte | 4 +- 3 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/__test__/tooltips.test.ts diff --git a/packages/ui/src/__test__/tooltips.test.ts b/packages/ui/src/__test__/tooltips.test.ts new file mode 100644 index 00000000000..ae77517739c --- /dev/null +++ b/packages/ui/src/__test__/tooltips.test.ts @@ -0,0 +1,117 @@ +// +// 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 IntlString } from '@hcengineering/platform' +import { get } from 'svelte/store' +import { closeTooltip, tooltip, tooltipstore } from '../tooltips' + +// svelte/store is shipped as an ES module only, provide a minimal synchronous replacement +jest.mock('svelte/store', () => { + type Subscriber = (value: any) => void + interface Store { + subscribe: (run: Subscriber) => () => void + } + const get = (store: Store): any => { + let value: any + store.subscribe((v) => (value = v))() + return value + } + const writable = (initial: any): Store & { set: Subscriber, update: (fn: (v: any) => any) => void } => { + let value = initial + const subscribers = new Set() + const set = (v: any): void => { + value = v + subscribers.forEach((s) => { + s(value) + }) + } + return { + set, + update: (fn) => { + set(fn(value)) + }, + subscribe: (run) => { + subscribers.add(run) + run(value) + return () => subscribers.delete(run) + } + } + } + const derived = (source: Store, fn: (v: any) => any): Store => ({ + subscribe: (run) => + source.subscribe((v) => { + run(fn(v)) + }) + }) + return { get, writable, derived } +}) + +const label = 'JPEG' as IntlString + +function hover (node: HTMLElement): void { + node.dispatchEvent(new MouseEvent('mousemove')) + jest.runOnlyPendingTimers() +} + +describe('tooltip action', () => { + beforeEach(() => { + jest.useFakeTimers() + closeTooltip() + }) + + afterEach(() => { + closeTooltip() + jest.useRealTimers() + }) + + it('shows the tooltip label on hover', () => { + const node = document.createElement('span') + tooltip(node, { label }) + hover(node) + expect(get(tooltipstore).label).toBe(label) + expect(get(tooltipstore).element).toBe(node) + }) + + it('shows a tooltip that becomes available after mount', () => { + const node = document.createElement('span') + const action = tooltip(node, undefined) + hover(node) + expect(get(tooltipstore).label).toBeUndefined() + + action.update({ label }) + hover(node) + expect(get(tooltipstore).label).toBe(label) + }) + + it('does not show a stale label after the tooltip is removed', () => { + const node = document.createElement('span') + const action = tooltip(node, { label }) + action.update(undefined) + expect(() => { + hover(node) + }).not.toThrow() + expect(get(tooltipstore).label).toBeUndefined() + }) + + it('closes the shown tooltip when it is removed', () => { + const node = document.createElement('span') + const action = tooltip(node, { label }) + hover(node) + expect(get(tooltipstore).label).toBe(label) + + action.update(undefined) + expect(get(tooltipstore).label).toBeUndefined() + }) +}) diff --git a/packages/ui/src/tooltips.ts b/packages/ui/src/tooltips.ts index dcfd364eccc..140eddd4ce3 100644 --- a/packages/ui/src/tooltips.ts +++ b/packages/ui/src/tooltips.ts @@ -27,19 +27,20 @@ export const tooltipstore = derived(modalStore, (modals) => { }) let toHandler: any +function hasTooltip (options: LabelAndProps | undefined): options is LabelAndProps { + return options !== undefined && (options.label !== undefined || options.component !== undefined) +} + export function tooltip (node: HTMLElement, options?: LabelAndProps): any { - if (options === undefined || (options.label === undefined && options.component === undefined)) { - // No tooltip - // TODO: Fix reactive options update in this case - return {} - } let opt = options const show = (): void => { + if (!hasTooltip(opt)) return const shown = !!(storedValue.label !== undefined || storedValue.component !== undefined) if (!shown) { if (opt?.kind !== 'submenu' || opt.timeout !== undefined) { clearTimeout(toHandler) toHandler = setTimeout(() => { + if (!hasTooltip(opt)) return showTooltip( opt.label, node, @@ -74,14 +75,19 @@ export function tooltip (node: HTMLElement, options?: LabelAndProps): any { } } const hide = (): void => { + if (!hasTooltip(opt)) return clearTimeout(toHandler) } node.addEventListener('mouseleave', hide) node.addEventListener('mousemove', show) return { - update (options: LabelAndProps) { + update (options?: LabelAndProps) { opt = options if (node !== storedValue.element) return + if (!hasTooltip(opt)) { + closeTooltip() + return + } const shown = !!(storedValue.label !== undefined || storedValue.component !== undefined) if (shown) { showTooltip( diff --git a/plugins/view-resources/src/components/EnumPresenter.svelte b/plugins/view-resources/src/components/EnumPresenter.svelte index 3a0428f6c2d..2e45fc28169 100644 --- a/plugins/view-resources/src/components/EnumPresenter.svelte +++ b/plugins/view-resources/src/components/EnumPresenter.svelte @@ -41,10 +41,12 @@ }, { limit: 1 } ) + + $: selectedLabel = items?.find((p) => p.id === value)?.label {#if onChange !== undefined && type !== undefined && !readonly} - +