feat(editor): Make workflows, credentials, executions and new canvas usable on mobile and touch devices (#12372)

This commit is contained in:
Alex Grozav 2025-01-06 17:09:32 +02:00 committed by GitHub
parent 1b9100032f
commit 06c9473210
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 294 additions and 29 deletions

View file

@ -34,7 +34,11 @@ const classes = computed(() => ({
<slot name="footer" />
</div>
</div>
<div v-if="$slots.append" data-test-id="card-append" :class="$style.append">
<div
v-if="$slots.append"
data-test-id="card-append"
:class="[$style.append, 'n8n-card-append']"
>
<slot name="append" />
</div>
</div>
@ -45,7 +49,7 @@ const classes = computed(() => ({
border-radius: var(--border-radius-large);
border: var(--border-base);
background-color: var(--color-background-xlight);
padding: var(--spacing-s);
padding: var(--card--padding, var(--spacing-s));
display: flex;
flex-direction: row;
width: 100%;
@ -101,5 +105,6 @@ const classes = computed(() => ({
display: flex;
align-items: center;
cursor: default;
width: var(--card--append--width, unset);
}
</style>

View file

@ -75,6 +75,38 @@ describe('useDeviceSupport()', () => {
});
});
describe('isMobileDevice', () => {
it('should be true for iOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'iphone' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for Android user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'android' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be false for non-mobile user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(false);
});
it('should be true for iPad user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipad' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for iPod user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipod' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
});
describe('isCtrlKeyPressed()', () => {
it('should return true for metaKey press on macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });

View file

@ -12,12 +12,16 @@ export function useDeviceSupport() {
!window.matchMedia('(any-pointer: fine)').matches,
);
const userAgent = ref(navigator.userAgent.toLowerCase());
const isMacOs = ref(
userAgent.value.includes('macintosh') ||
const isIOs = ref(
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipad') ||
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipod'),
);
const isAndroidOs = ref(userAgent.value.includes('android'));
const isMacOs = ref(userAgent.value.includes('macintosh') || isIOs.value);
const isMobileDevice = ref(isIOs.value || isAndroidOs.value);
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
@ -30,7 +34,10 @@ export function useDeviceSupport() {
return {
userAgent: userAgent.value,
isTouchDevice: isTouchDevice.value,
isAndroidOs: isAndroidOs.value,
isIOs: isIOs.value,
isMacOs: isMacOs.value,
isMobileDevice: isMobileDevice.value,
controlKeyCode: controlKeyCode.value,
isCtrlKeyPressed,
};

View file

@ -5,6 +5,7 @@
@use './base.scss';
@use './pagination.scss';
@use './dialog.scss';
@use './display.scss';
// @use "./autocomplete.scss";
@use './dropdown.scss';
@use './dropdown-menu.scss';

View file

@ -0,0 +1,20 @@
@use '../common/var';
@mixin breakpoint($name) {
@if map-has-key(var.$breakpoints-spec, $name) {
$query: map-get(var.$breakpoints-spec, $name);
$media-query: '';
@each $key, $value in $query {
$media-query: '#{$media-query} and (#{$key}: #{$value})';
}
$media-query: unquote(str-slice($media-query, 6)); // Remove the initial ' and '
@media screen and #{$media-query} {
@content;
}
} @else {
@error "No breakpoint named `#{$name}` found in `$breakpoints-spec`.";
}
}

View file

@ -0,0 +1,6 @@
@forward 'breakpoints';
@forward 'button';
@forward 'config';
@forward 'function';
@forward 'mixins';
@forward 'utils';

View file

@ -192,6 +192,8 @@ watch(defaultLocale, (newLocale) => {
.header {
grid-area: header;
z-index: var(--z-index-app-header);
min-width: 0;
min-height: 0;
}
.sidebar {

View file

@ -162,6 +162,7 @@ function moveResource() {
<template #append>
<div :class="$style.cardActions" @click.stop>
<ProjectCardBadge
:class="$style.cardBadge"
:resource="data"
:resource-type="ResourceType.Credential"
:resource-type-label="resourceTypeLabel"
@ -180,9 +181,10 @@ function moveResource() {
<style lang="scss" module>
.cardLink {
--card--padding: 0 0 0 var(--spacing-s);
transition: box-shadow 0.3s ease;
cursor: pointer;
padding: 0 0 0 var(--spacing-s);
align-items: stretch;
&:hover {
@ -215,4 +217,22 @@ function moveResource() {
padding: 0 var(--spacing-s) 0 0;
cursor: default;
}
@include mixins.breakpoint('sm-and-down') {
.cardLink {
--card--padding: 0 var(--spacing-s) var(--spacing-s);
--card--append--width: 100%;
flex-wrap: wrap;
}
.cardActions {
width: 100%;
padding: 0;
}
.cardBadge {
margin-right: auto;
}
}
</style>

View file

@ -220,7 +220,7 @@ function hideGithubButton() {
@update:model-value="onTabSelected"
/>
</div>
<div v-if="showGitHubButton" class="github-button">
<div v-if="showGitHubButton" class="github-button hidden-sm-and-down">
<div class="github-button-container">
<GithubButton
href="https://github.com/n8n-io/n8n"
@ -264,6 +264,7 @@ function hideGithubButton() {
font-size: 0.9em;
font-weight: 400;
padding: var(--spacing-xs) var(--spacing-m);
overflow: auto;
}
.github-button {

View file

@ -800,6 +800,8 @@ $--header-spacing: 20px;
.name {
color: $custom-font-dark;
font-size: 15px;
display: block;
min-width: 150px;
}
.activator {
@ -807,7 +809,6 @@ $--header-spacing: 20px;
font-weight: 400;
font-size: 13px;
line-height: $--text-line-height;
display: flex;
align-items: center;
> span {
@ -845,24 +846,24 @@ $--header-spacing: 20px;
display: flex;
align-items: center;
gap: var(--spacing-m);
flex-wrap: wrap;
flex-wrap: nowrap;
}
</style>
<style module lang="scss">
.container {
position: relative;
top: -1px;
width: 100%;
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
}
.group {
display: flex;
gap: var(--spacing-xs);
}
.hiddenInput {
display: none;
}

View file

@ -13,6 +13,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { useUIStore } from '@/stores/ui.store';
import { DRAG_EVENT_DATA_KEY } from '@/constants';
import { useAssistantStore } from '@/stores/assistant.store';
import N8nIconButton from 'n8n-design-system/components/N8nIconButton/IconButton.vue';
export interface Props {
active?: boolean;
@ -145,6 +146,14 @@ onBeforeUnmount(() => {
[$style.active]: showScrim,
}"
/>
<N8nIconButton
v-if="active"
:class="$style.close"
type="secondary"
icon="times"
aria-label="Close Node Creator"
@click="emit('closeNodeCreator')"
/>
<SlideTransition>
<div
v-if="active"
@ -168,13 +177,14 @@ onBeforeUnmount(() => {
font-weight: var(--font-weight-bold);
}
.nodeCreator {
--node-creator-width: #{$node-creator-width};
--node-icon-color: var(--color-text-base);
position: fixed;
top: $header-height;
bottom: 0;
right: 0;
z-index: var(--z-index-node-creator);
width: $node-creator-width;
width: var(--node-creator-width);
color: $node-creator-text-color;
}
@ -194,4 +204,24 @@ onBeforeUnmount(() => {
opacity: 0.7;
}
}
.close {
position: absolute;
z-index: calc(var(--z-index-node-creator) + 1);
top: var(--spacing-xs);
right: var(--spacing-xs);
background: transparent;
border: 0;
display: none;
}
@media screen and (max-width: #{$node-creator-width + $sidebar-width}) {
.nodeCreator {
--node-creator-width: calc(100vw - #{$sidebar-width});
}
.close {
display: inline-flex;
}
}
</style>

View file

@ -260,7 +260,7 @@ function onBackButton() {
height: 100%;
background-color: $node-creator-background-color;
--color-background-node-icon-badge: var(--color-background-xlight);
width: 385px;
width: var(--node-creator-width);
display: flex;
flex-direction: column;
@ -303,6 +303,7 @@ function onBackButton() {
line-height: 24px;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-l);
margin: 0;
.hasBg & {
font-size: var(--font-size-s-m);

View file

@ -126,7 +126,7 @@ const badgeTooltip = computed(() => {
</script>
<template>
<N8nTooltip :disabled="!badgeTooltip" placement="top">
<div class="mr-xs">
<div :class="$style.wrapper" v-bind="$attrs">
<N8nBadge
v-if="badgeText"
:class="$style.badge"
@ -153,6 +153,10 @@ const badgeTooltip = computed(() => {
</template>
<style lang="scss" module>
.wrapper {
margin-right: var(--spacing-xs);
}
.badge {
padding: var(--spacing-4xs) var(--spacing-2xs);
background-color: var(--color-background-xlight);

View file

@ -106,10 +106,10 @@ const onSelect = (action: string) => {
<template>
<div>
<div :class="[$style.projectHeader]">
<div :class="[$style.projectDetails]">
<div :class="$style.projectHeader">
<div :class="$style.projectDetails">
<ProjectIcon :icon="headerIcon" :border-less="true" size="medium" />
<div>
<div :class="$style.headerActions">
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
<N8nText color="text-light">
<slot name="subtitle">
@ -147,7 +147,8 @@ const onSelect = (action: string) => {
</template>
<style lang="scss" module>
.projectHeader {
.projectHeader,
.projectDescription {
display: flex;
align-items: center;
justify-content: space-between;
@ -163,4 +164,16 @@ const onSelect = (action: string) => {
.actions {
padding: var(--spacing-2xs) 0 var(--spacing-l);
}
@include mixins.breakpoint('xs-only') {
.projectHeader {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.headerActions {
margin-left: auto;
}
}
</style>

View file

@ -268,6 +268,7 @@ function moveResource() {
<template #append>
<div :class="$style.cardActions" @click.stop>
<ProjectCardBadge
:class="$style.cardBadge"
:resource="data"
:resource-type="ResourceType.Workflow"
:resource-type-label="resourceTypeLabel"
@ -330,4 +331,22 @@ function moveResource() {
padding: 0 var(--spacing-s) 0 0;
cursor: default;
}
@include mixins.breakpoint('sm-and-down') {
.cardLink {
--card--padding: 0 var(--spacing-s) var(--spacing-s);
--card--append--width: 100%;
flex-direction: column;
}
.cardActions {
width: 100%;
padding: 0 var(--spacing-s) var(--spacing-s);
}
.cardBadge {
margin-right: auto;
}
}
</style>

View file

@ -93,7 +93,7 @@ const props = withDefaults(
},
);
const { controlKeyCode } = useDeviceSupport();
const { isMobileDevice, controlKeyCode } = useDeviceSupport();
const vueFlow = useVueFlow({ id: props.id, deleteKeyCode: null });
const {
@ -143,9 +143,10 @@ const disableKeyBindings = computed(() => !props.keyBindings);
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys
*/
const panningKeyCode = ref<string[]>([' ', controlKeyCode]);
const panningMouseButton = ref<number[]>([1]);
const selectionKeyCode = ref<true | null>(true);
const panningKeyCode = ref<string[] | true>(isMobileDevice ? true : [' ', controlKeyCode]);
const panningMouseButton = ref<number[] | true>(isMobileDevice ? true : [1]);
const selectionKeyCode = ref<string | true | null>(isMobileDevice ? 'Shift' : true);
onKeyDown(panningKeyCode.value, () => {
selectionKeyCode.value = null;

View file

@ -483,6 +483,10 @@ const goToUpgrade = () => {
width: 100%;
padding: var(--spacing-l) var(--spacing-2xl) 0;
max-width: var(--content-container-width);
@include mixins.breakpoint('xs-only') {
padding: var(--spacing-xs) var(--spacing-xs) 0;
}
}
.execList {

View file

@ -129,4 +129,14 @@ onBeforeRouteLeave(async (to, _, next) => {
.content {
flex: 1;
}
@include mixins.breakpoint('sm-and-down') {
.container {
flex-direction: column;
}
.content {
flex: 1 1 50%;
}
}
</style>

View file

@ -265,6 +265,7 @@ const goToUpgrade = () => {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.heading {
@ -314,9 +315,10 @@ const goToUpgrade = () => {
bottom: 0;
margin-left: calc(-1 * var(--spacing-l));
border-top: var(--border-base);
width: 100%;
& > div {
width: 309px;
width: 100%;
background-color: var(--color-background-light);
margin-top: 0 !important;
}

View file

@ -99,10 +99,16 @@ onBeforeMount(async () => {
:class="$style['filter-button']"
data-test-id="resources-list-filters-trigger"
>
<n8n-badge v-show="filtersLength > 0" theme="primary" class="mr-4xs">
<n8n-badge
v-show="filtersLength > 0"
:class="$style['filter-button-count']"
theme="primary"
>
{{ filtersLength }}
</n8n-badge>
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
<span :class="$style['filter-button-text']">
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
</span>
</n8n-button>
</template>
<div :class="$style['filters-dropdown']" data-test-id="resources-list-filters-dropdown">
@ -139,6 +145,25 @@ onBeforeMount(async () => {
.filter-button {
height: 40px;
align-items: center;
.filter-button-count {
margin-right: var(--spacing-4xs);
@include mixins.breakpoint('xs-only') {
margin-right: 0;
}
}
@media screen and (max-width: 480px) {
.filter-button-text {
text-indent: -10000px;
}
// Remove icon margin when the "Filters" text is hidden
:global(span + span) {
margin: 0;
}
}
}
.filters-dropdown {

View file

@ -17,6 +17,10 @@
box-sizing: border-box;
align-content: start;
padding: var(--spacing-l) var(--spacing-2xl) 0;
@include mixins.breakpoint('sm-and-down') {
padding: var(--spacing-s) var(--spacing-s) 0;
}
}
.content {

View file

@ -475,6 +475,7 @@ onMounted(async () => {
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.filters {
@ -483,10 +484,24 @@ onMounted(async () => {
grid-auto-columns: max-content;
gap: var(--spacing-2xs);
align-items: center;
width: 100%;
@include mixins.breakpoint('xs-only') {
grid-template-columns: 1fr auto;
grid-auto-flow: row;
> *:last-child {
grid-column: auto;
}
}
}
.search {
max-width: 240px;
@include mixins.breakpoint('sm-and-down') {
max-width: 100%;
}
}
.listWrapper {
@ -497,6 +512,10 @@ onMounted(async () => {
.sort-and-filter {
white-space: nowrap;
@include mixins.breakpoint('sm-and-down') {
width: 100%;
}
}
.datatable {

View file

@ -56,6 +56,10 @@
fill: var(--color-foreground-dark);
opacity: 0.2;
}
@include mixins.breakpoint('xs-only') {
display: none;
}
}
/**
@ -100,3 +104,20 @@
.vue-flow__edge-label.selected {
z-index: 1 !important;
}
/**
* Controls
*/
.vue-flow__controls {
margin: var(--spacing-s);
@include mixins.breakpoint('xs-only') {
max-width: calc(100% - 3 * var(--spacing-s) - var(--spacing-2xs));
overflow: auto;
margin-left: 0;
margin-right: 0;
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
}
}

View file

@ -1772,11 +1772,13 @@ onBeforeUnmount(() => {
align-items: center;
left: 50%;
transform: translateX(-50%);
bottom: var(--spacing-l);
bottom: var(--spacing-s);
width: auto;
@media (max-width: $breakpoint-2xs) {
bottom: 150px;
@include mixins.breakpoint('sm-only') {
left: auto;
right: var(--spacing-s);
transform: none;
}
button {
@ -1788,6 +1790,17 @@ onBeforeUnmount(() => {
&:first-child {
margin: 0;
}
@include mixins.breakpoint('xs-only') {
text-indent: -10000px;
width: 42px;
height: 42px;
padding: 0;
span {
margin: 0;
}
}
}
}

View file

@ -85,7 +85,11 @@ export default mergeConfig(
css: {
preprocessorOptions: {
scss: {
additionalData: '\n@use "@/n8n-theme-variables.scss" as *;\n',
additionalData: [
'',
'@use "@/n8n-theme-variables.scss" as *;',
'@use "n8n-design-system/css/mixins" as mixins;',
].join('\n'),
},
},
},