feat(editor): Implement new banners framework (#6603)

*  Implemented new grid row - banners
*  Fixing node creator and executions sidebar position after layout update
* 💄 Added configurable round corners to the Callout component
*  Fixing mouse position detection and main tab bar position
*  Implemented basic banner component structure
*  Implemented banner state and dismiss logic
*  Fixing grid layout. Updating banners height state dynamically
*  Fix zoom to fit position, mouse position in demo mode and callout vertical alignment
*  Implementing proper trial banners logic
* 💄 Only showing execution usage data once the sidebar is fully expanded
*  Implemented permanent/temporary dismiss logic for v1 flag
*  Minor refactoring of banner logic
*  Updating permanent dismiss logic to work with all banners
* 👕 Fixing linting errors
* ✔️ Updating Callout component test snapshots
* 💄 Tweaking zoom to fit position
* ✔️ Updating testing endpoints to use new store data
*  Added banners unit tests
* ✔️ Fixing failing banner tests
*  Added more banner tests
*  Updating banners dimensions on resize, removing leftover code
* ✔️ Removing store import from API file
* 👕 Fixing lint errors
*  Updating migration files
*  Using query parameters in migrations
* 👌 Addressing design review feedback
*  Updating upgrade plan button click
*  Updating the migrations syntax
* 👌 Updating permanent banner dismiss endpoint and back-end logic
* 👌 Refactoring trial banner component and ui store
* 👌 Addressing more points from code review
* 👌 Moving DOM logic from the store
* ✔️ Updated callout component snapshots
* 👌 Updating mysql migration file
* ✔️ Updating e2e test canvas coordinates after setting it's position to absolute
* 👌 Addressing back-end review feedback
* 👌 Improving typing around banners
* 👕 Fixing lint errors
This commit is contained in:
Milorad FIlipović
2023-07-14 15:36:17 +02:00
committed by GitHub
parent ff0759530d
commit 4240e76253
47 changed files with 637 additions and 221 deletions

View File

@@ -26,7 +26,7 @@
<template #footer="{ close }">
<div :class="$style.footer">
<el-checkbox :value="checked" @change="handleCheckboxChange">{{
$locale.baseText('activationModal.dontShowAgain')
$locale.baseText('generic.dontShowAgain')
}}</el-checkbox>
<n8n-button @click="close" :label="$locale.baseText('activationModal.gotIt')" />
</div>

View File

@@ -19,6 +19,8 @@ import { BREAKPOINT_SM, BREAKPOINT_MD, BREAKPOINT_LG, BREAKPOINT_XL } from '@/co
import { genericHelpers } from '@/mixins/genericHelpers';
import { debounceHelper } from '@/mixins/debounce';
import { useUIStore } from '@/stores';
import { getBannerRowHeight } from '@/utils';
export default defineComponent({
name: 'BreakpointsObserver',
@@ -41,6 +43,10 @@ export default defineComponent({
},
onResizeEnd() {
this.$data.width = window.innerWidth;
this.$nextTick(async () => {
const bannerHeight = await getBannerRowHeight();
useUIStore().updateBannersHeight(bannerHeight);
});
},
},
computed: {

View File

@@ -76,7 +76,7 @@ onBeforeUnmount(() => {
.zoomMenu {
position: absolute;
width: 210px;
bottom: 108px;
bottom: var(--spacing-2xl);
left: 35px;
line-height: 25px;
color: #444;

View File

@@ -172,6 +172,7 @@ export default defineComponent({
}
.top-menu {
position: relative;
display: flex;
align-items: center;
font-size: 0.9em;

View File

@@ -54,7 +54,7 @@ export default defineComponent({
.container {
position: absolute;
top: 47px;
left: calc(50% + 100px);
left: 50%;
transform: translateX(-50%);
min-height: 30px;
display: flex;
@@ -62,10 +62,6 @@ export default defineComponent({
background-color: var(--color-foreground-base);
border-radius: var(--border-radius-base);
transition: all 150ms ease-in-out;
&.menuCollapsed {
left: 52%;
}
}
@media screen and (max-width: 430px) {

View File

@@ -29,7 +29,7 @@
<template #beforeLowerMenu>
<ExecutionsUsage
:cloud-plan-data="currentPlanAndUsageData"
v-if="!isCollapsed && userIsTrialing"
v-if="fullyExpanded && userIsTrialing"
/></template>
<template #menuSuffix>
<div>

View File

@@ -139,7 +139,7 @@ export default defineComponent({
<style lang="scss" module>
.nodeButtonsWrapper {
position: fixed;
position: absolute;
width: 150px;
height: 200px;
top: 0;
@@ -164,9 +164,9 @@ export default defineComponent({
}
.nodeCreatorButton {
position: fixed;
position: absolute;
text-align: center;
top: calc(#{$header-height} + var(--spacing-s));
top: var(--spacing-s);
right: var(--spacing-s);
pointer-events: all !important;

View File

@@ -5,6 +5,7 @@
<div
v-if="active"
:class="$style.nodeCreator"
:style="nodeCreatorInlineStyle"
ref="nodeCreator"
v-click-outside="onClickOutside"
@dragover="onDragOver"
@@ -30,6 +31,7 @@ import { useViewStacks } from './composables/useViewStacks';
import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
import { useActionsGenerator } from './composables/useActionsGeneration';
import NodesListPanel from './Panel/NodesListPanel.vue';
import { useUIStore } from '@/stores';
export interface Props {
active?: boolean;
@@ -42,6 +44,7 @@ const emit = defineEmits<{
(event: 'closeNodeCreator'): void;
(event: 'nodeTypeSelected', value: string[]): void;
}>();
const uiStore = useUIStore();
const { setShowScrim, setActions, setMergeNodes } = useNodeCreatorStore();
const { generateMergedNodesAndActions } = useActionsGenerator();
@@ -55,6 +58,10 @@ const showScrim = computed(() => useNodeCreatorStore().showScrim);
const viewStacksLength = computed(() => useViewStacks().viewStacks.length);
const nodeCreatorInlineStyle = computed(() => {
return { top: `${uiStore.bannersHeight + uiStore.headerHeight}px` };
});
function onClickOutside(event: Event) {
// We need to prevent cases where user would click inside the node creator
// and try to drag non-draggable element. In that case the click event would

View File

@@ -247,7 +247,7 @@ export default defineComponent({
<style lang="scss" module>
.container {
min-width: $sidebar-expanded-width;
height: 100vh;
height: 100%;
background-color: var(--color-background-xlight);
border-right: var(--border-base);
position: relative;

View File

@@ -1,76 +0,0 @@
<template>
<n8n-callout
v-if="shouldDisplay"
theme="warning"
icon="info-circle"
override-icon
:class="$style['v1-banner']"
>
<span v-html="locale.baseText('banners.v1.message')"></span>
{{ '' }}
<a v-if="isInstanceOwner" @click="dismissBanner('v1', 'permanent')">
<span v-html="locale.baseText('banners.v1.action')"></span>
</a>
<template #trailingContent>
<n8n-icon
size="small"
icon="xmark"
:title="locale.baseText('banners.v1.iconTitle')"
:class="$style.xmark"
@click="dismissBanner('v1', 'temporary')"
/>
</template>
</n8n-callout>
</template>
<script setup lang="ts">
import { VIEWS } from '@/constants';
import { computed } from 'vue';
import { useUIStore, useUsersStore, useRootStore } from '@/stores';
import { useRoute } from 'vue-router/composables';
import { i18n as locale } from '@/plugins/i18n';
const { isInstanceOwner } = useUsersStore();
const { dismissBanner } = useUIStore();
const shouldDisplay = computed(() => {
if (!useRootStore().versionCli.startsWith('1.')) return false;
if (useUIStore().banners.v1.dismissed) return false;
const VIEWABLE_AT: string[] = [
VIEWS.HOMEPAGE,
VIEWS.COLLECTION,
VIEWS.TEMPLATE,
VIEWS.TEMPLATES,
VIEWS.CREDENTIALS,
VIEWS.VARIABLES,
VIEWS.WORKFLOWS,
VIEWS.EXECUTIONS,
];
const { name } = useRoute();
if (name && VIEWABLE_AT.includes(name)) return true;
return false;
});
</script>
<style module lang="scss">
.v1-banner {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: 999;
a {
text-decoration: underline;
}
.xmark {
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,151 @@
import { PiniaVuePlugin } from 'pinia';
import { render, within } from '@testing-library/vue';
import { merge } from 'lodash-es';
import userEvent from '@testing-library/user-event';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import BannerStack from '@/components/banners/BannerStack.vue';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
let uiStore: ReturnType<typeof useUIStore>;
let usersStore: ReturnType<typeof useUsersStore>;
const DEFAULT_SETUP = {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
},
[STORES.UI]: {
banners: {
V1: { dismissed: false },
TRIAL: { dismissed: false },
TRIAL_OVER: { dismissed: false },
},
},
[STORES.USERS]: {
currentUserId: 'aaa-bbb',
users: {
'aaa-bbb': {
id: 'aaa-bbb',
globalRole: {
id: '1',
name: 'owner',
scope: 'global',
},
},
'bbb-bbb': {
id: 'bbb-bbb',
globalRoleId: 2,
globalRole: {
id: '2',
name: 'member',
scope: 'global',
},
},
},
},
},
}),
};
const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) =>
render(BannerStack, merge(DEFAULT_SETUP, renderOptions), (vue) => {
vue.use(PiniaVuePlugin);
});
describe('BannerStack', () => {
beforeEach(() => {
uiStore = useUIStore();
usersStore = useUsersStore();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render default configuration', async () => {
const { getByTestId } = renderComponent();
const bannerStack = getByTestId('banner-stack');
expect(bannerStack).toBeInTheDocument();
expect(within(bannerStack).getByTestId('banners-TRIAL')).toBeInTheDocument();
expect(within(bannerStack).getByTestId('banners-TRIAL_OVER')).toBeInTheDocument();
expect(within(bannerStack).getByTestId('banners-V1')).toBeInTheDocument();
});
it('should not render dismissed banners', async () => {
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: merge(
{
[STORES.UI]: {
banners: {
V1: { dismissed: true },
TRIAL: { dismissed: true },
},
},
},
DEFAULT_SETUP.pinia,
),
}),
});
const bannerStack = getByTestId('banner-stack');
expect(bannerStack).toBeInTheDocument();
expect(within(bannerStack).queryByTestId('banners-V1')).not.toBeInTheDocument();
expect(within(bannerStack).queryByTestId('banners-TRIAL')).not.toBeInTheDocument();
expect(within(bannerStack).getByTestId('banners-TRIAL_OVER')).toBeInTheDocument();
});
it('should dismiss banner on click', async () => {
const { getByTestId } = renderComponent();
const dismissBannerSpy = vi
.spyOn(useUIStore(), 'dismissBanner')
.mockImplementation(async (banner, mode) => {});
const closeTrialBannerButton = getByTestId('banner-TRIAL_OVER-close');
expect(closeTrialBannerButton).toBeInTheDocument();
await userEvent.click(closeTrialBannerButton);
expect(dismissBannerSpy).toHaveBeenCalledWith('TRIAL_OVER');
});
it('should permanently dismiss banner on click', async () => {
const { getByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: merge(DEFAULT_SETUP.pinia, {
[STORES.UI]: {
banners: {
V1: { dismissed: false },
},
},
}),
}),
});
const dismissBannerSpy = vi
.spyOn(useUIStore(), 'dismissBanner')
.mockImplementation(async (banner, mode) => {});
const permanentlyDismissBannerLink = getByTestId('banner-confirm-v1');
expect(permanentlyDismissBannerLink).toBeInTheDocument();
await userEvent.click(permanentlyDismissBannerLink);
expect(dismissBannerSpy).toHaveBeenCalledWith('V1', 'permanent');
});
it('should not render permanent dismiss link if user is not owner', async () => {
const { queryByTestId } = renderComponent({
pinia: createTestingPinia({
initialState: merge(DEFAULT_SETUP.pinia, {
[STORES.USERS]: {
currentUserId: 'bbb-bbb',
},
}),
}),
});
expect(queryByTestId('banner-confirm-v1')).not.toBeInTheDocument();
});
});

View File

@@ -8,11 +8,10 @@ import { SOURCE_CONTROL_PULL_MODAL_KEY, STORES } from '@/constants';
import { i18nInstance } from '@/plugins/i18n';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useUsersStore, useSourceControlStore, useUIStore } from '@/stores';
import { useSourceControlStore, useUIStore } from '@/stores';
let pinia: ReturnType<typeof createTestingPinia>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let uiStore: ReturnType<typeof useUIStore>;
const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) => {
@@ -44,7 +43,6 @@ describe('MainSidebarSourceControl', () => {
sourceControlStore = useSourceControlStore();
uiStore = useUIStore();
usersStore = useUsersStore();
});
it('should render nothing', async () => {

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import TrialOverBanner from '@/components/banners/TrialOverBanner.vue';
import TrialBanner from '@/components/banners/TrialBanner.vue';
import V1Banner from '@/components/banners/V1Banner.vue';
import { useUIStore } from '@/stores/ui.store';
import { onMounted, watch } from 'vue';
import { getBannerRowHeight } from '@/utils';
import type { Banners } from 'n8n-workflow';
const uiStore = useUIStore();
function shouldShowBanner(bannerName: Banners) {
return uiStore.banners[bannerName].dismissed === false;
}
async function updateCurrentBannerHeight() {
const bannerHeight = await getBannerRowHeight();
uiStore.updateBannersHeight(bannerHeight);
}
onMounted(async () => {
await updateCurrentBannerHeight();
});
watch(uiStore.banners, async () => {
await updateCurrentBannerHeight();
});
</script>
<template>
<div data-test-id="banner-stack">
<trial-over-banner v-if="shouldShowBanner('TRIAL_OVER')" />
<trial-banner v-if="shouldShowBanner('TRIAL')" />
<v1-banner v-if="shouldShowBanner('V1')" />
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script lang="ts" setup>
import { useUIStore } from '@/stores/ui.store';
import type { Banners } from 'n8n-workflow';
interface Props {
name: Banners;
theme?: string;
customIcon?: string;
dismissible?: boolean;
}
const uiStore = useUIStore();
const props = withDefaults(defineProps<Props>(), {
theme: 'info',
dismissible: true,
});
const emit = defineEmits(['close']);
async function onCloseClick() {
await uiStore.dismissBanner(props.name);
emit('close');
}
</script>
<template>
<n8n-callout
:theme="props.theme"
:icon="props.customIcon"
iconSize="medium"
:roundCorners="false"
:data-test-id="`banners-${props.name}`"
>
<div :class="$style.mainContent">
<slot name="mainContent" />
</div>
<template #trailingContent>
<div :class="$style.trailingContent">
<slot name="trailingContent" />
<n8n-icon
v-if="dismissible"
size="small"
icon="times"
title="Dismiss"
class="clickable"
:data-test-id="`banner-${props.name}-close`"
@click="onCloseClick"
/>
</div>
</template>
</n8n-callout>
</template>
<style lang="scss" module>
.mainContent {
display: flex;
gap: var(--spacing-4xs);
}
.trailingContent {
display: flex;
align-items: center;
gap: var(--spacing-l);
}
</style>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import BaseBanner from '@/components/banners/BaseBanner.vue';
import { i18n as locale } from '@/plugins/i18n';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { computed } from 'vue';
import { useUIStore } from '@/stores';
const trialDaysLeft = computed(() => {
const { trialDaysLeft } = useCloudPlanStore();
return -1 * trialDaysLeft;
});
const messageText = computed(() => {
return locale.baseText('banners.trial.message', {
adjustToNumber: trialDaysLeft.value,
interpolate: { count: String(trialDaysLeft.value) },
});
});
function onUpdatePlanClick() {
useUIStore().goToUpgrade('canvas-nav', 'upgrade-canvas-nav', 'redirect');
}
</script>
<template>
<base-banner name="TRIAL" theme="custom">
<template #mainContent>
<span>{{ messageText }}</span>
</template>
<template #trailingContent>
<n8n-button type="success" @click="onUpdatePlanClick" icon="gem" size="small">{{
locale.baseText('generic.upgradeNow')
}}</n8n-button>
</template>
</base-banner>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import BaseBanner from '@/components/banners/BaseBanner.vue';
import { i18n as locale } from '@/plugins/i18n';
import { useUIStore } from '@/stores';
function onUpdatePlanClick() {
useUIStore().goToUpgrade('canvas-nav', 'upgrade-canvas-nav', 'redirect');
}
</script>
<template>
<base-banner customIcon="info-circle" theme="warning" name="TRIAL_OVER">
<template #mainContent>
<span>{{ locale.baseText('banners.trialOver.message') }}</span>
</template>
<template #trailingContent>
<n8n-button type="success" @click="onUpdatePlanClick" icon="gem" size="small">{{
locale.baseText('generic.upgradeNow')
}}</n8n-button>
</template>
</base-banner>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import BaseBanner from '@/components/banners/BaseBanner.vue';
import { i18n as locale } from '@/plugins/i18n';
import { useUsersStore } from '@/stores';
import { useUIStore } from '@/stores/ui.store';
const uiStore = useUIStore();
const { isInstanceOwner } = useUsersStore();
async function dismissPermanently() {
await uiStore.dismissBanner('V1', 'permanent');
}
</script>
<template>
<base-banner customIcon="info-circle" theme="warning" name="V1">
<template #mainContent>
<span v-html="locale.baseText('banners.v1.message')"></span>
<a
v-if="isInstanceOwner"
:class="$style.link"
@click="dismissPermanently"
data-test-id="banner-confirm-v1"
>
<span v-html="locale.baseText('generic.dontShowAgain')"></span>
</a>
</template>
</base-banner>
</template>
<style lang="scss" module>
a,
.link {
font-weight: var(--font-weight-bold);
text-decoration: underline;
}
</style>