feat(editor): implement executions preview via the new executions tab in node view (#4311)

*  Added main header tabs with current workflow execution count
*  feat(editor): header tab navigation (no-changelog) (#4244)
*  Adding current workflow execution list to the Vuex store
*  Updating current workflow executions after running a workflow from the node view
*  Keeping the tab view content alive when switching tabs in main header
*  Updating main header controls to work with current workflow regardless of active tab
* 🐛 Fixing a bug with previous WF executions still visible after creating a new WF
*  Updating saved status when new WF is created
*  Implemented initial version of execution perview
*  Keeping the WF view alive when switching to executions tab in new navigation
*  Implemented executions landing page
*  Simplifying node view navigation
*  Updating executions view zoom and selection to work with the new layout
*  Using N8nRadioButtons component for main header tabs
* 💄 Implementing executions page states. Minor refactoring.
*  Merge conflict fixes and pieces of code that were left behind
*  Fixing layout and scrolling changes introduced after sync with master branch
*  Removing keep-alive from node view which broke template opening and some more leftover code
* ✔️ Fixing linting errors
* ✔️ One more lint error
*  Implemented executions preview using iframes
*  Fixing zoom menu positioning in iframe and adding different loading types to workflow preview
*  Fixing navigation to and from WF templates and template loading
*  Updating and fixing navigation to and from node view
* 👌 Addressing previous PR comments
* 🐛 Fixing infinite loading when saving a new workflow
* 🐛 Handling opening already opened WF when not on Node view
*  Implemented empty states for executions view
*  Adding execute button shake flag to the store so it doesn't mess up navigation by modifying route params
* 💄 Started adding new styles to execution sidebar
* 💄 Adding hover style for execution list
*  Added ExecutionsCard component and added executions helper mixin
* ✔️ Fixing leftover conflict
* ✔️ One more conflict
*  Implemented retry execution menu and manual execution icon. Other minor updates
*  Implemented executions filtering
* 💄 Updating running executions details in preview
*  Added info accordion to executions sidebar
*  Implemented auto-refresh for executions sidebar
* 💄 Adding running execution landing page, minor fixes
* 💄 General refactoring
* ✔️ Adding leftover conflict changes
* ✔️ Updating `InfoTip` component test snapshots
* ✔️ Fixing linting error
* ✔️ Fixing lint errors in vuex store module
* 👌 Started addressing review feedback
*  Updating executions preview behaviour when filters are applied
* 🐛 Fixing a bug where nodes and connections disappear if something is saved from executions view before loading WF in the main NodeView
* 🐛 Fixing pasting in executions view and wrong workflow activator state
*  Improved workflow switching and navigation, updated error message when trying to paste into execution
*  Some more navigation updates
* 💄 Fixing tab centering, execution filter button layout, added auto-refresh checkbox
* 🐛 Fixing a bug when saving workflow using save button
* 💄 Addressing design feedback, added delete execution button
*  Moving main execution logic to the root executions view
*  Implemented execution delete function
*  Updating how switching tabs for new unsaved workflows work
*  Remembering active execution when switching tabs
* 💄 Addressing design feedback regarding info accordion
* 💄 Updating execution card styling
*  Resetting executions when creating new workflow
* Fixing lint error
*  Hiding executions preview is active execution is not in the results. Updated execution list spacing
*  Fixing navigation to and from templates and executions
*  Implemented execution lazy loading and added new background to execution preview
* 💄 Disabling import when on executions tab
*  Handling opening executions from different workflow
*  Updating active execution on route change
*  Updating execution tab detection
*  Simplifying and updating navigation. Adding new route for new workflows
*  Updating workflow saving logic to work with new routes
* 🐛 Fixing a bug when returning to executions from different workflow
* 💄 Updating executions info accordion and node details view modal in execution preview
* 💄 Updating workflow activated modal to point to new executions view
*  Implemented opening new executions view from execution modal
*  Handling jsplumb init errors, updating unknown executions style
*  Updating main sidebar after syncing branch
*  Opening new trigger menu from executions view
* 💄 Updating sidebar resize behaviour
* ✔️ Fixing lint errors
*  Loading executions when mounting executions view
*  Resetting execution data when creating a new workflow
* 💄 Minor wording updates
*  Not reloading node view when new workflows are saved
* Removing leftover console log
* 🐛 Fixed a bug with save dialog not appearing when leaving executions tab
*  Updating manual execution settings detection in info accordion
* 💄 Addressing UI issues found during bug bash
* Fixing workflow saving logic
*  Preventing navigation if clicked tab is already opened
*  Updating lazy loading behaviour
*  Updating delete executions flow
*  Added retry executions button to the execution preview
*  Adding empty execution state, updating trigger detection logic, removing listeners when node view is not active
* 💄 Cosmetic code improvements
*  Trying the performance fix for nodeBase
*  Removing the `NodeBase`fix
* 🐛 Fixing a bug when saving the current workflow
* 👌 Addressing code review feedback
This commit is contained in:
Milorad FIlipović
2022-10-26 10:02:56 +02:00
committed by GitHub
parent 99157cf581
commit d833345092
46 changed files with 2473 additions and 184 deletions

View File

@@ -1,8 +1,22 @@
<template>
<div class="node-view-root" @dragover="onDragOver" @drop="onDrop">
<div class="node-view-wrapper" :class="workflowClasses" @touchstart="mouseDown" @touchend="mouseUp"
@touchmove="mouseMoveNodeWorkflow" @mousedown="mouseDown" v-touch:tap="touchTap" @mouseup="mouseUp"
@wheel="wheelScroll">
<div :class="$style['content']">
<div
class="node-view-root"
id="node-view-root"
@dragover="onDragOver"
@drop="onDrop"
>
<div
class="node-view-wrapper"
:class="workflowClasses"
@touchstart="mouseDown"
@touchend="mouseUp"
@touchmove="mouseMoveNodeWorkflow"
@mousedown="mouseDown"
v-touch:tap="touchTap"
@mouseup="mouseUp"
@wheel="wheelScroll"
>
<div id="node-view-background" class="node-view-background" :style="backgroundStyle" />
<div
id="node-view"
@@ -12,7 +26,7 @@
>
<canvas-add-button
:style="canvasAddButtonStyle"
@click="showTriggerCreator('tirger_placeholder_button')"
@click="showTriggerCreator('trigger_placeholder_button')"
v-show="showCanvasAddButton"
:showTooltip="!containsTrigger && showTriggerMissingTooltip"
:position="canvasAddButtonPosition"
@@ -117,6 +131,7 @@
@click.stop="clearExecutionData()" />
</div>
</div>
</div>
</template>
<script lang="ts">
@@ -130,6 +145,7 @@ import once from 'lodash/once';
import {
FIRST_ONBOARDING_PROMPT_TIMEOUT,
MAIN_HEADER_TABS,
MODAL_CANCEL,
MODAL_CLOSE,
MODAL_CONFIRMED,
@@ -205,11 +221,14 @@ import {
IWorkflowToShare,
} from '@/Interface';
import { mapGetters } from 'vuex';
import '../plugins/N8nCustomConnectorType';
import '../plugins/PlusEndpointType';
import { getAccountAge } from '@/modules/userHelpers';
import { dataPinningEventBus } from "@/event-bus/data-pinning-event-bus";
import { debounceHelper } from '@/components/mixins/debounce';
import { getNodeViewTab } from '@/components/helpers';
import { Route } from 'vue-router';
interface AddNodeOptions {
position?: XYPosition;
@@ -249,8 +268,44 @@ export default mixins(
},
watch: {
// Listen to route changes and load the workflow accordingly
'$route': 'initView',
activeNode() {
'$route' (to: Route, from: Route) {
const currentTab = getNodeViewTab(to);
const nodeViewNotInitialized = !this.$store.getters['ui/isNodeViewInitialized'];
let workflowChanged =
from.params.name !== to.params.name &&
// Both 'new' and __EMPTY__ are new workflow names, so ignore them when detecting if wf changed
!(from.params.name === 'new' && this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID) &&
// Also ignore if workflow id changes when saving new workflow
to.params.action !== 'workflowSave';
const isOpeningTemplate = to.name === VIEWS.TEMPLATE_IMPORT;
// When entering this tab:
if (currentTab === MAIN_HEADER_TABS.WORKFLOW || isOpeningTemplate) {
if (workflowChanged || nodeViewNotInitialized || isOpeningTemplate) {
this.startLoading();
if (nodeViewNotInitialized) {
const previousDirtyState = this.$store.getters.getStateIsDirty;
this.resetWorkspace();
this.$store.commit('setStateDirty', previousDirtyState);
}
this.initView().then(() => {
this.stopLoading();
if (this.blankRedirect) {
this.blankRedirect = false;
}
});
}
}
// Also, when landing on executions tab, check if workflow data is changed
if (currentTab === MAIN_HEADER_TABS.EXECUTIONS) {
workflowChanged = from.params.name !== to.params.name && !(to.params.name === 'new' && from.params.name === undefined);
if (workflowChanged) {
// This will trigger node view to update next time workflow tab is opened
this.$store.commit('ui/setNodeViewInitialized', false);
}
}
},
activeNode () {
// When a node gets set as active deactivate the create-menu
this.createNodeActive = false;
},
@@ -261,30 +316,40 @@ export default mixins(
},
},
async beforeRouteLeave(to, from, next) {
this.$store.commit('setSubworkflowExecutionError', null);
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
this.$store.commit('setStateDirty', false);
const nextTab = getNodeViewTab(to);
// Only react if leaving workflow tab and going to a separate page
if (!nextTab) {
// Skip check if in the middle of template import
if (from.name === VIEWS.TEMPLATE_IMPORT) {
next();
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CLOSE) {
next(false);
return;
}
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CLOSE) {
next(false);
}
} else {
next();
}
} else {
next();
}
@@ -351,7 +416,11 @@ export default mixins(
};
},
backgroundStyle(): object {
return CanvasHelpers.getBackgroundStyles(this.nodeViewScale, this.getNodeViewOffsetPosition);
return CanvasHelpers.getBackgroundStyles(
this.nodeViewScale,
this.$store.getters.getNodeViewOffsetPosition,
this.isExecutionPreview,
);
},
workflowClasses() {
const returnClasses = [];
@@ -374,9 +443,14 @@ export default mixins(
workflowRunning(): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
currentWorkflow (): string {
return this.$route.params.name || this.$store.getters.workflowId;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
allTriggersDisabled(): boolean {
const disabledTriggerNodes = this.triggerNodes.filter(node => node.disabled);
return disabledTriggerNodes.length === this.triggerNodes.length;
},
triggerNodes(): INodeUi[] {
@@ -414,6 +488,7 @@ export default mixins(
dropPrevented: false,
renamingActive: false,
showStickyButton: false,
isExecutionPreview: false,
showTriggerMissingTooltip: false,
canvasAddButtonPosition: [1, 1] as XYPosition,
workflowData: null as INewWorkflowData | null,
@@ -442,7 +517,7 @@ export default mixins(
this.$externalHooks().run('nodeView.onRunNode', telemetryPayload);
this.runWorkflow(nodeName, source);
},
onRunWorkflow() {
async onRunWorkflow() {
this.getWorkflowDataToSave().then((workflowData) => {
const telemetryPayload = {
workflow_id: this.$store.getters.workflowId,
@@ -450,9 +525,10 @@ export default mixins(
};
this.$telemetry.track('User clicked execute workflow button', telemetryPayload);
this.$externalHooks().run('nodeView.onRunWorkflow', telemetryPayload);
});
this.runWorkflow();
await this.runWorkflow();
},
onRunContainerClick() {
if (this.containsTrigger && !this.allTriggersDisabled) return;
@@ -559,8 +635,8 @@ export default mixins(
this.$nextTick(() => this.$store.commit('nodeCreator/setShowTabs', false));
},
async openExecution(executionId: string) {
this.startLoading();
this.resetWorkspace();
let data: IExecutionResponse | undefined;
try {
data = await this.restApi().getExecution(executionId);
@@ -571,14 +647,11 @@ export default mixins(
);
return;
}
if (data === undefined) {
throw new Error(`Execution with id "${executionId}" could not be found!`);
}
this.$store.commit('setWorkflowName', { newName: data.workflowData.name, setStateDirty: false });
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$store.commit('setWorkflowExecutionData', data);
this.$store.commit('setWorkflowPinData', data.workflowData.pinData);
@@ -587,8 +660,6 @@ export default mixins(
this.zoomToFit();
this.$store.commit('setStateDirty', false);
});
this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId });
this.$telemetry.track('User opened read-only execution', { workflow_id: data.workflowData.id, execution_mode: data.mode, execution_finished: data.finished });
@@ -617,7 +688,6 @@ export default mixins(
message: errorMessage,
type: 'error',
}, shouldTrack);
if (data.data.resultData.error.stack) {
// Display some more information for now in console to make debugging easier
// TODO: Improve this in the future by displaying in UI
@@ -626,7 +696,6 @@ export default mixins(
}
}
}
if ((data as IExecutionsSummary).waitTill) {
this.$showMessage({
title: this.$locale.baseText('nodeView.thisExecutionHasntFinishedYet'),
@@ -635,6 +704,7 @@ export default mixins(
duration: 0,
});
}
this.stopLoading();
},
async importWorkflowExact(data: { workflow: IWorkflowDataUpdate }) {
if (!data.workflow.nodes || !data.workflow.connections) {
@@ -654,9 +724,13 @@ export default mixins(
});
},
async openWorkflowTemplate(templateId: string) {
this.startLoading();
this.setLoadingText(this.$locale.baseText('nodeView.loadingTemplate'));
this.resetWorkspace();
this.$store.commit('workflows/setCurrentWorkflowExecutions', []);
this.$store.commit('workflows/setActiveWorkflowExecution', null);
let data: IWorkflowTemplate | undefined;
try {
this.$externalHooks().run('template.requested', { templateId });
@@ -685,14 +759,16 @@ export default mixins(
this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData', data.name);
this.$nextTick(() => {
this.zoomToFit();
this.$store.commit('setStateDirty', true);
});
this.$externalHooks().run('template.open', { templateId, templateName: data.name, workflow: data.workflow });
this.stopLoading();
},
async openWorkflow(workflowId: string) {
this.startLoading();
this.resetWorkspace();
let data: IWorkflowDb | undefined;
try {
data = await this.restApi().getWorkflow(workflowId);
@@ -712,28 +788,23 @@ export default mixins(
),
);
}
this.$store.commit('setActive', data.active || false);
this.$store.commit('setWorkflowId', workflowId);
this.$store.commit('setWorkflowName', { newName: data.name, setStateDirty: false });
this.$store.commit('setWorkflowSettings', data.settings || {});
this.$store.commit('setWorkflowPinData', data.pinData || {});
const tags = (data.tags || []) as ITag[];
this.$store.commit('tags/upsertTags', tags);
const tagIds = tags.map((tag) => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds || []);
await this.addNodes(data.nodes, data.connections);
if (!this.credentialsUpdated) {
this.$store.commit('setStateDirty', false);
}
this.zoomToFit();
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.stopLoading();
return data;
},
touchTap(e: MouseEvent | TouchEvent) {
@@ -1188,7 +1259,7 @@ export default mixins(
return;
}
const { zoomLevel, offset } = CanvasHelpers.getZoomToFit(nodes);
const {zoomLevel, offset} = CanvasHelpers.getZoomToFit(nodes, !this.isDemo);
this.setZoomLevel(zoomLevel);
this.$store.commit('setNodeViewOffsetPosition', { newOffset: offset });
@@ -1263,13 +1334,15 @@ export default mixins(
return;
}
},
/**
* This method gets called when data got pasted into the window
*/
async receivedCopyPasteData(plainTextData: string): Promise<void> {
let workflowData: IWorkflowDataUpdate | undefined;
if (this.editAllowedCheck() === false) {
return;
}
// Check if it is an URL which could contain workflow data
if (plainTextData.match(/^http[s]?:\/\/.*\.json$/i)) {
// Pasted data points to a possible workflow JSON file
@@ -2089,8 +2162,11 @@ export default mixins(
});
},
async newWorkflow(): Promise<void> {
this.startLoading();
await this.resetWorkspace();
this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData');
this.$store.commit('workflows/setCurrentWorkflowExecutions', []);
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.$store.commit('setStateDirty', false);
this.setZoomLevel(1);
@@ -2123,6 +2199,9 @@ export default mixins(
this.$telemetry.track('welcome note inserted');
}
}
this.$store.commit('ui/setNodeViewInitialized', true);
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.stopLoading();
}),
async initView(): Promise<void> {
if (this.$route.params.action === 'workflowSave') {
@@ -2131,7 +2210,6 @@ export default mixins(
this.$store.commit('setStateDirty', false);
return Promise.resolve();
}
if (this.blankRedirect) {
this.blankRedirect = false;
}
@@ -2144,7 +2222,6 @@ export default mixins(
const executionId = this.$route.params.id;
await this.openExecution(executionId);
} else {
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
@@ -2155,7 +2232,6 @@ export default mixins(
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
@@ -2163,7 +2239,6 @@ export default mixins(
return Promise.resolve();
}
}
// Load a workflow
let workflowId = null as string | null;
if (this.$route.params.name) {
@@ -2185,15 +2260,14 @@ export default mixins(
// Open existing workflow
await this.openWorkflow(workflowId);
}
} else {
} else if (this.$route.meta?.nodeView === true) {
// Create new workflow
await this.newWorkflow();
}
}
this.$store.commit('ui/setNodeViewInitialized', true);
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
window.addEventListener("beforeunload", (e) => {
if (this.isDemo){
return;
@@ -2206,7 +2280,6 @@ export default mixins(
this.startLoading(
this.$locale.baseText('nodeView.redirecting'),
);
return;
}
});
@@ -3097,6 +3170,7 @@ export default mixins(
if (json && json.command === 'openWorkflow') {
try {
await this.importWorkflowExact(json);
this.isExecutionPreview = false;
} catch (e) {
if (window.top) {
window.top.postMessage(JSON.stringify({ command: 'error', message: this.$locale.baseText('openWorkflow.workflowImportError') }), '*');
@@ -3107,6 +3181,20 @@ export default mixins(
type: 'error',
});
}
} else if (json && json.command === 'openExecution') {
try {
await this.openExecution(json.executionId);
this.isExecutionPreview = true;
} catch (e) {
if (window.top) {
window.top.postMessage(JSON.stringify({ command: 'error', message: this.$locale.baseText('nodeView.showError.openExecution.title') }), '*');
}
this.$showMessage({
title: this.$locale.baseText('nodeView.showError.openExecution.title'),
message: (e as Error).message,
type: 'error',
});
}
}
} catch (e) {
}
@@ -3169,7 +3257,6 @@ export default mixins(
this.addNode(nodeTypeName, { position });
},
},
async mounted() {
this.$titleReset();
window.addEventListener('message', this.onPostMessageReceived);
@@ -3178,6 +3265,7 @@ export default mixins(
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
this.startLoading();
this.resetWorkspace();
const loadPromises = [
this.loadActiveWorkflows(),
@@ -3202,7 +3290,9 @@ export default mixins(
this.instance.ready(async () => {
try {
this.initNodeView();
try {
this.initNodeView();
} catch {} // This will break if mounted after jsplumb has been initiated from executions preview, so continue if it breaks
await this.initView();
if (window.top) {
window.top.postMessage(JSON.stringify({ command: 'n8nReady', version: this.$store.getters.versionCli }), '*');
@@ -3257,7 +3347,33 @@ export default mixins(
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
},
activated() {
const openSideMenu = this.$store.getters['ui/getAddFirstStepOnLoad'];
if (openSideMenu) {
this.showTriggerCreator('trigger_placeholder_button');
}
this.$store.commit('ui/setAddFirstStepOnLoad', false);
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
window.addEventListener('message', this.onPostMessageReceived);
this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
},
deactivated () {
document.removeEventListener('keydown', this.keyDown);
document.removeEventListener('keyup', this.keyUp);
window.removeEventListener('message', this.onPostMessageReceived);
this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
},
destroyed() {
this.resetWorkspace();
this.$store.commit('setStateDirty', false);
@@ -3276,18 +3392,15 @@ export default mixins(
.zoom-menu {
$--zoom-menu-margin: 15;
position: fixed;
position: absolute;
left: $sidebar-width + $--zoom-menu-margin;
width: 210px;
bottom: 44px;
bottom: 108px;
left: 35px;
line-height: 25px;
color: #444;
padding-right: 5px;
&:not(.demo-zoom-menu).expanded {
left: $sidebar-expanded-width + $--zoom-menu-margin;
}
button {
border: var(--border-base);
}
@@ -3315,6 +3428,8 @@ export default mixins(
}
.node-view-root {
position: relative;
flex: 1;
overflow: hidden;
background-color: var(--color-canvas-background);
width: 100%;
@@ -3474,3 +3589,36 @@ export default mixins(
}
}
</style>
<style module lang="scss">
.content {
position: relative;
display: flex;
overflow: auto;
height: 100vh;
}
.shake {
animation: 1s 200ms shake;
}
@keyframes shake {
10%, 90% {
transform: translate3d(-1px, 0, 0);
}
20%, 80% {
transform: translate3d(2px, 0, 0);
}
30%, 50%, 70% {
transform: translate3d(-4px, 0, 0);
}
40%, 60% {
transform: translate3d(4px, 0, 0);
}
}
</style>