fix(editor): Fix log view style bugs (#16312)

This commit is contained in:
Suguru Inoue
2025-06-16 14:50:57 +02:00
committed by GitHub
parent c22ca2cb4a
commit 58a556430c
6 changed files with 105 additions and 56 deletions

View File

@@ -55,7 +55,7 @@ global.IntersectionObserver = IntersectionObserver;
// Mocks for useDeviceSupport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
value: vi.fn((query) => ({
matches: true,
media: query,
onchange: null,

View File

@@ -190,8 +190,9 @@ watch(
<N8nIconButton
v-if="!isCompact || !props.latestInfo?.deleted"
type="secondary"
size="medium"
size="small"
icon="edit"
icon-size="medium"
style="color: var(--color-text-base)"
:style="{
visibility: props.canOpenNdv ? '' : 'hidden',
@@ -220,6 +221,8 @@ watch(
v-if="!isCompact || hasChildren"
type="secondary"
size="small"
:icon="props.expanded ? 'chevron-down' : 'chevron-up'"
icon-size="medium"
:square="true"
:style="{
visibility: hasChildren ? '' : 'hidden',
@@ -228,9 +231,7 @@ watch(
:class="$style.toggleButton"
:aria-label="locale.baseText('logs.overview.body.toggleRow')"
@click.stop="emit('toggleExpanded')"
>
<N8nIcon size="medium" :icon="props.expanded ? 'chevron-down' : 'chevron-up'" />
</N8nButton>
/>
</div>
</template>
@@ -242,6 +243,7 @@ watch(
overflow: hidden;
position: relative;
z-index: 1;
padding-inline-end: var(--spacing-5xs);
& > * {
overflow: hidden;
@@ -269,7 +271,7 @@ watch(
}
.selected:not(:hover).error & {
background-color: var(--color-danger-tint-2);
background-color: var(--color-callout-danger-background);
}
}
@@ -342,6 +344,10 @@ watch(
.compactErrorIcon {
flex-grow: 0;
flex-shrink: 0;
width: 26px;
display: flex;
align-items: center;
justify-content: center;
.container:hover & {
display: none;
@@ -377,10 +383,6 @@ watch(
align-items: center;
justify-content: center;
&:last-child {
margin-inline-end: var(--spacing-5xs);
}
&:hover {
background: transparent;
}

View File

@@ -3,6 +3,8 @@ import { computed, defineComponent, h, ref } from 'vue';
import { usePiPWindow } from './usePiPWindow';
import { waitFor } from '@testing-library/vue';
import { renderComponent } from '@/__tests__/render';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
describe(usePiPWindow, () => {
const documentPictureInPicture: NonNullable<Window['documentPictureInPicture']> = {
@@ -15,6 +17,10 @@ describe(usePiPWindow, () => {
}) as unknown as Window,
};
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
});
describe('canPopOut', () => {
it('should return false if window.documentPictureInPicture is not available', () => {
const MyComponent = defineComponent({

View File

@@ -1,9 +1,12 @@
import { IsInPiPWindowSymbol } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { applyThemeToBody } from '@/stores/ui.utils';
import { useProvideTooltipAppendTo } from '@n8n/design-system/composables/useTooltipAppendTo';
import {
computed,
type ComputedRef,
onBeforeUnmount,
onScopeDispose,
provide,
type Ref,
ref,
@@ -26,6 +29,35 @@ interface UsePiPWindowReturn {
pipWindow?: Ref<Window | undefined>;
}
function isStyle(node: Node): node is HTMLElement {
return (
node instanceof HTMLStyleElement ||
(node instanceof HTMLLinkElement && node.rel === 'stylesheet')
);
}
function syncStyleMutations(destination: Window, mutations: MutationRecord[]) {
const currentStyles = destination.document.head.querySelectorAll('style, link[rel="stylesheet"]');
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (isStyle(node)) {
destination.document.head.appendChild(node.cloneNode(true));
}
}
for (const node of mutation.removedNodes) {
if (isStyle(node)) {
for (const found of currentStyles) {
if (found.isEqualNode(node)) {
found.remove();
}
}
}
}
}
}
/**
* A composable that allows to pop out given content in document PiP (picture-in-picture) window
*/
@@ -48,6 +80,15 @@ export function usePiPWindow({
const tooltipContainer = computed(() =>
isPoppedOut.value ? (content.value ?? undefined) : undefined,
);
const uiStore = useUIStore();
const observer = new MutationObserver((mutations) => {
if (pipWindow.value) {
syncStyleMutations(pipWindow.value, mutations);
}
});
// Copy over dynamic styles to PiP window to support lazily imported modules
observer.observe(document.head, { childList: true, subtree: true });
provide(IsInPiPWindowSymbol, isPoppedOut);
useProvideTooltipAppendTo(tooltipContainer);
@@ -104,6 +145,22 @@ export function usePiPWindow({
immediate: true,
});
// It seems "prefers-color-scheme: dark" media query matches in PiP window by default
// So we're enforcing currently applied theme in the main window by setting data-theme in PiP's body element
watch(
[() => uiStore.appliedTheme, pipWindow],
([theme, pip]) => {
if (pip) {
applyThemeToBody(theme, pip);
}
},
{ immediate: true },
);
onScopeDispose(() => {
observer.disconnect();
});
onBeforeUnmount(() => {
isUnmounting.value = true;
pipWindow.value?.close();

View File

@@ -41,6 +41,7 @@ import {
FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
LOCAL_STORAGE_THEME,
} from '@/constants';
import { STORES } from '@n8n/stores';
import type {
@@ -59,26 +60,21 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store';
import { dismissBannerPermanently } from '@n8n/rest-api-client';
import type { BannerName } from '@n8n/api-types';
import {
addThemeToBody,
getPreferredTheme,
getThemeOverride,
isValidTheme,
updateTheme,
} from './ui.utils';
import { applyThemeToBody, getThemeOverride, isValidTheme } from './ui.utils';
import { computed, ref } from 'vue';
import type { Connection } from '@vue-flow/core';
import { useLocalStorage } from '@vueuse/core';
import { useLocalStorage, useMediaQuery } from '@vueuse/core';
import type { EventBus } from '@n8n/utils/event-bus';
import type { ProjectSharingData } from '@/types/projects.types';
import identity from 'lodash/identity';
let savedTheme: ThemeOption = 'system';
try {
const value = getThemeOverride();
if (isValidTheme(value)) {
if (value !== null) {
savedTheme = value;
addThemeToBody(value);
applyThemeToBody(value);
}
} catch (e) {}
@@ -87,7 +83,13 @@ type UiStore = ReturnType<typeof useUIStore>;
export const useUIStore = defineStore(STORES.UI, () => {
const activeActions = ref<string[]>([]);
const activeCredentialType = ref<string | null>(null);
const theme = ref<ThemeOption>(savedTheme);
const theme = useLocalStorage<ThemeOption>(LOCAL_STORAGE_THEME, savedTheme, {
writeDefaults: false,
serializer: {
read: (value) => (isValidTheme(value) ? value : savedTheme),
write: identity,
},
});
const modalsById = ref<Record<string, ModalState>>({
...Object.fromEntries(
[
@@ -232,12 +234,10 @@ export const useUIStore = defineStore(STORES.UI, () => {
const workflowsStore = useWorkflowsStore();
const rootStore = useRootStore();
// Keep track of the preferred theme and update it when the system preference changes
const preferredTheme = getPreferredTheme();
const preferredSystemTheme = ref<AppliedThemeOption>(preferredTheme.theme);
preferredTheme.mediaQuery?.addEventListener('change', () => {
preferredSystemTheme.value = getPreferredTheme().theme;
});
const isDarkThemePreferred = useMediaQuery('(prefers-color-scheme: dark)');
const preferredSystemTheme = computed<AppliedThemeOption>(() =>
isDarkThemePreferred.value ? 'dark' : 'light',
);
const appliedTheme = computed(() => {
return theme.value === 'system' ? preferredSystemTheme.value : theme.value;
@@ -352,7 +352,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
const setTheme = (newTheme: ThemeOption): void => {
theme.value = newTheme;
updateTheme(newTheme);
applyThemeToBody(newTheme);
};
const setMode = (name: keyof Modals, mode: string): void => {
@@ -550,7 +550,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
sidebarMenuCollapsed,
sidebarMenuCollapsedPreference,
bannerStack,
theme,
theme: computed(() => theme.value),
modalsById,
currentView,
isAnyModalOpen,

View File

@@ -1,11 +1,12 @@
import type { AppliedThemeOption, ThemeOption } from '@/Interface';
import { useStorage } from '@/composables/useStorage';
import { LOCAL_STORAGE_THEME } from '@/constants';
import type { AppliedThemeOption, ThemeOption } from '@/Interface';
const themeRef = useStorage(LOCAL_STORAGE_THEME);
export function addThemeToBody(theme: AppliedThemeOption) {
window.document.body.setAttribute('data-theme', theme);
export function applyThemeToBody(theme: ThemeOption, window_?: Window) {
if (theme === 'system') {
(window_ ?? window).document.body.removeAttribute('data-theme');
} else {
(window_ ?? window).document.body.setAttribute?.('data-theme', theme); // setAttribute can be missing in jsdom environment
}
}
export function isValidTheme(theme: string | null): theme is AppliedThemeOption {
@@ -13,29 +14,12 @@ export function isValidTheme(theme: string | null): theme is AppliedThemeOption
}
// query param allows overriding theme for demo view in preview iframe without flickering
export function getThemeOverride() {
return getQueryParam('theme') || themeRef.value;
export function getThemeOverride(): AppliedThemeOption | null {
const override = getQueryParam('theme') ?? localStorage.getItem(LOCAL_STORAGE_THEME);
return isValidTheme(override) ? override : null;
}
function getQueryParam(paramName: string): string | null {
return new URLSearchParams(window.location.search).get(paramName);
}
export function updateTheme(theme: ThemeOption) {
if (theme === 'system') {
window.document.body.removeAttribute('data-theme');
themeRef.value = null;
} else {
addThemeToBody(theme);
themeRef.value = theme;
}
}
export function getPreferredTheme(): { theme: AppliedThemeOption; mediaQuery: MediaQueryList } {
const isDarkModeQuery = !!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
return {
theme: isDarkModeQuery?.matches ? 'dark' : 'light',
mediaQuery: isDarkModeQuery,
};
}