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 // Mocks for useDeviceSupport
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: vi.fn().mockImplementation((query) => ({ value: vi.fn((query) => ({
matches: true, matches: true,
media: query, media: query,
onchange: null, onchange: null,

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
import { IsInPiPWindowSymbol } from '@/constants'; 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 { useProvideTooltipAppendTo } from '@n8n/design-system/composables/useTooltipAppendTo';
import { import {
computed, computed,
type ComputedRef, type ComputedRef,
onBeforeUnmount, onBeforeUnmount,
onScopeDispose,
provide, provide,
type Ref, type Ref,
ref, ref,
@@ -26,6 +29,35 @@ interface UsePiPWindowReturn {
pipWindow?: Ref<Window | undefined>; 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 * 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(() => const tooltipContainer = computed(() =>
isPoppedOut.value ? (content.value ?? undefined) : undefined, 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); provide(IsInPiPWindowSymbol, isPoppedOut);
useProvideTooltipAppendTo(tooltipContainer); useProvideTooltipAppendTo(tooltipContainer);
@@ -104,6 +145,22 @@ export function usePiPWindow({
immediate: true, 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(() => { onBeforeUnmount(() => {
isUnmounting.value = true; isUnmounting.value = true;
pipWindow.value?.close(); pipWindow.value?.close();

View File

@@ -41,6 +41,7 @@ import {
FROM_AI_PARAMETERS_MODAL_KEY, FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY, IMPORT_WORKFLOW_URL_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY, WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
LOCAL_STORAGE_THEME,
} from '@/constants'; } from '@/constants';
import { STORES } from '@n8n/stores'; import { STORES } from '@n8n/stores';
import type { import type {
@@ -59,26 +60,21 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { dismissBannerPermanently } from '@n8n/rest-api-client'; import { dismissBannerPermanently } from '@n8n/rest-api-client';
import type { BannerName } from '@n8n/api-types'; import type { BannerName } from '@n8n/api-types';
import { import { applyThemeToBody, getThemeOverride, isValidTheme } from './ui.utils';
addThemeToBody,
getPreferredTheme,
getThemeOverride,
isValidTheme,
updateTheme,
} from './ui.utils';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { Connection } from '@vue-flow/core'; 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 { EventBus } from '@n8n/utils/event-bus';
import type { ProjectSharingData } from '@/types/projects.types'; import type { ProjectSharingData } from '@/types/projects.types';
import identity from 'lodash/identity';
let savedTheme: ThemeOption = 'system'; let savedTheme: ThemeOption = 'system';
try { try {
const value = getThemeOverride(); const value = getThemeOverride();
if (isValidTheme(value)) { if (value !== null) {
savedTheme = value; savedTheme = value;
addThemeToBody(value); applyThemeToBody(value);
} }
} catch (e) {} } catch (e) {}
@@ -87,7 +83,13 @@ type UiStore = ReturnType<typeof useUIStore>;
export const useUIStore = defineStore(STORES.UI, () => { export const useUIStore = defineStore(STORES.UI, () => {
const activeActions = ref<string[]>([]); const activeActions = ref<string[]>([]);
const activeCredentialType = ref<string | null>(null); 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>>({ const modalsById = ref<Record<string, ModalState>>({
...Object.fromEntries( ...Object.fromEntries(
[ [
@@ -232,12 +234,10 @@ export const useUIStore = defineStore(STORES.UI, () => {
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
// Keep track of the preferred theme and update it when the system preference changes const isDarkThemePreferred = useMediaQuery('(prefers-color-scheme: dark)');
const preferredTheme = getPreferredTheme(); const preferredSystemTheme = computed<AppliedThemeOption>(() =>
const preferredSystemTheme = ref<AppliedThemeOption>(preferredTheme.theme); isDarkThemePreferred.value ? 'dark' : 'light',
preferredTheme.mediaQuery?.addEventListener('change', () => { );
preferredSystemTheme.value = getPreferredTheme().theme;
});
const appliedTheme = computed(() => { const appliedTheme = computed(() => {
return theme.value === 'system' ? preferredSystemTheme.value : theme.value; return theme.value === 'system' ? preferredSystemTheme.value : theme.value;
@@ -352,7 +352,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
const setTheme = (newTheme: ThemeOption): void => { const setTheme = (newTheme: ThemeOption): void => {
theme.value = newTheme; theme.value = newTheme;
updateTheme(newTheme); applyThemeToBody(newTheme);
}; };
const setMode = (name: keyof Modals, mode: string): void => { const setMode = (name: keyof Modals, mode: string): void => {
@@ -550,7 +550,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
sidebarMenuCollapsed, sidebarMenuCollapsed,
sidebarMenuCollapsedPreference, sidebarMenuCollapsedPreference,
bannerStack, bannerStack,
theme, theme: computed(() => theme.value),
modalsById, modalsById,
currentView, currentView,
isAnyModalOpen, 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 { LOCAL_STORAGE_THEME } from '@/constants';
import type { AppliedThemeOption, ThemeOption } from '@/Interface';
const themeRef = useStorage(LOCAL_STORAGE_THEME); export function applyThemeToBody(theme: ThemeOption, window_?: Window) {
if (theme === 'system') {
export function addThemeToBody(theme: AppliedThemeOption) { (window_ ?? window).document.body.removeAttribute('data-theme');
window.document.body.setAttribute('data-theme', 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 { 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 // query param allows overriding theme for demo view in preview iframe without flickering
export function getThemeOverride() { export function getThemeOverride(): AppliedThemeOption | null {
return getQueryParam('theme') || themeRef.value; const override = getQueryParam('theme') ?? localStorage.getItem(LOCAL_STORAGE_THEME);
return isValidTheme(override) ? override : null;
} }
function getQueryParam(paramName: string): string | null { function getQueryParam(paramName: string): string | null {
return new URLSearchParams(window.location.search).get(paramName); 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,
};
}