refactor(editor): Continue porting components over to composition API (no-changelog) (#8893)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-03-15 12:43:08 +01:00 committed by GitHub
parent 80c4bc443a
commit 6c693e1afd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 989 additions and 1253 deletions

View file

@ -19,6 +19,7 @@
import { computed, useCssModule } from 'vue';
import N8nText from '../N8nText';
import N8nIcon from '../N8nIcon';
import type { IconSize } from '@/types/icon';
const THEMES = ['info', 'success', 'secondary', 'warning', 'danger', 'custom'] as const;
export type CalloutTheme = (typeof THEMES)[number];
@ -33,7 +34,7 @@ const CALLOUT_DEFAULT_ICONS = {
interface CalloutProps {
theme: CalloutTheme;
icon?: string;
iconSize?: string;
iconSize?: IconSize;
iconless?: boolean;
slim?: boolean;
roundCorners?: boolean;
@ -58,7 +59,7 @@ const getIcon = computed(
() => props.icon ?? CALLOUT_DEFAULT_ICONS?.[props.theme] ?? CALLOUT_DEFAULT_ICONS.info,
);
const getIconSize = computed(() => {
const getIconSize = computed<IconSize>(() => {
if (props.iconSize) {
return props.iconSize;
}

View file

@ -34,11 +34,11 @@
<slot v-if="hasDefaultSlot" />
<N8nSelect
v-else-if="type === 'select' || type === 'multi-select'"
ref="inputRef"
:class="{ [$style.multiSelectSmallTags]: tagSize === 'small' }"
:model-value="modelValue"
:placeholder="placeholder"
:multiple="type === 'multi-select'"
ref="inputRef"
:disabled="disabled"
:name="name"
:teleported="teleported"
@ -57,8 +57,8 @@
</N8nSelect>
<N8nInput
v-else
:name="name"
ref="inputRef"
:name="name"
:type="type"
:placeholder="placeholder"
:model-value="modelValue"

View file

@ -41,7 +41,7 @@
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import N8nFormInput from '../N8nFormInput';
import type { IFormInput } from '../../types';
import type { IFormInput, Validatable } from '../../types';
import ResizeObserver from '../ResizeObserver';
import type { EventBus } from '../../utils';
import { createEventBus } from '../../utils';
@ -83,7 +83,7 @@ export default defineComponent({
data() {
return {
showValidationWarnings: false,
values: {} as { [key: string]: unknown },
values: {} as { [key: string]: Validatable },
validity: {} as { [key: string]: boolean },
};
},
@ -141,12 +141,15 @@ export default defineComponent({
this.showValidationWarnings = true;
if (this.isReadyToSubmit) {
const toSubmit = this.filteredInputs.reduce<{ [key: string]: unknown }>((accu, input) => {
if (this.values[input.name]) {
accu[input.name] = this.values[input.name];
}
return accu;
}, {});
const toSubmit = this.filteredInputs.reduce(
(accu, input) => {
if (this.values[input.name]) {
accu[input.name] = this.values[input.name];
}
return accu;
},
{} as { [key: string]: Validatable },
);
this.$emit('submit', toSubmit);
}
},

View file

@ -39,7 +39,7 @@
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import { onMounted, ref } from 'vue';
import N8nText from '../N8nText';
import N8nIcon from '../N8nIcon';
import type { IconColor } from '@/types/icon';
@ -70,16 +70,16 @@ const props = withDefaults(defineProps<InfoAccordionProps>(), {
});
const $emit = defineEmits(['click:body', 'tooltipClick']);
let expanded = false;
const expanded = ref(false);
onMounted(() => {
props.eventBus.on('expand', () => {
expanded = true;
expanded.value = true;
});
expanded = props.initiallyExpanded;
expanded.value = props.initiallyExpanded;
});
const toggle = () => {
expanded = !expanded;
expanded.value = !expanded.value;
};
const onClick = (e: MouseEvent) => $emit('click:body', e);

View file

@ -9,15 +9,16 @@
</template>
<script lang="ts" setup>
import type { RouteLocationRaw } from 'vue-router';
import N8nText from '../N8nText';
import N8nRoute, { type RouteTo } from '../N8nRoute';
import N8nRoute from '../N8nRoute';
import type { TextSize } from '@/types/text';
const THEME = ['primary', 'danger', 'text', 'secondary'] as const;
interface LinkProps {
to?: RouteLocationRaw;
size?: TextSize;
to?: RouteTo;
newWindow?: boolean;
bold?: boolean;
underline?: boolean;

View file

@ -8,7 +8,7 @@
v-html="htmlContent"
/>
<div v-else :class="$style.markdown">
<div v-for="(block, index) in loadingBlocks" :key="index">
<div v-for="(_, index) in loadingBlocks" :key="index">
<N8nLoading :loading="loading" :rows="loadingRows" animated variant="p" />
<div :class="$style.spacer" />
</div>

View file

@ -53,104 +53,76 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ElMenu } from 'element-plus';
import N8nMenuItem from '../N8nMenuItem';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import type { IMenuItem, RouteObject } from '../../types';
import type { IMenuItem } from '../../types';
import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil';
export default defineComponent({
name: 'N8nMenu',
components: {
ElMenu,
N8nMenuItem,
},
props: {
type: {
type: String,
default: 'primary',
validator: (value: string): boolean => ['primary', 'secondary'].includes(value),
},
defaultActive: {
type: String,
},
collapsed: {
type: Boolean,
default: false,
},
transparentBackground: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: 'router',
validator: (value: string): boolean => ['router', 'tabs'].includes(value),
},
tooltipDelay: {
type: Number,
default: 300,
},
items: {
type: Array as PropType<IMenuItem[]>,
default: (): IMenuItem[] => [],
},
modelValue: {
type: [String, Boolean],
default: '',
},
},
data() {
return {
activeTab: this.value,
};
},
computed: {
upperMenuItems(): IMenuItem[] {
return this.items.filter(
(item: IMenuItem) => item.position === 'top' && item.available !== false,
);
},
lowerMenuItems(): IMenuItem[] {
return this.items.filter(
(item: IMenuItem) => item.position === 'bottom' && item.available !== false,
);
},
currentRoute(): RouteObject {
return (
this.$route || {
name: '',
path: '',
}
);
},
},
mounted() {
if (this.mode === 'router') {
const found = this.items.find((item) =>
doesMenuItemMatchCurrentRoute(item, this.currentRoute),
);
interface MenuProps {
type?: 'primary' | 'secondary';
defaultActive?: string;
collapsed?: boolean;
transparentBackground?: boolean;
mode?: 'router' | 'tabs';
tooltipDelay?: number;
items?: IMenuItem[];
modelValue?: string;
}
this.activeTab = found ? found.id : '';
} else {
this.activeTab = this.items.length > 0 ? this.items[0].id : '';
}
this.$emit('update:modelValue', this.activeTab);
},
methods: {
onSelect(item: IMenuItem): void {
if (this.mode === 'tabs') {
this.activeTab = item.id;
}
this.$emit('select', item.id);
this.$emit('update:modelValue', item.id);
},
},
const props = withDefaults(defineProps<MenuProps>(), {
type: 'primary',
collapsed: false,
transparentBackground: false,
mode: 'router',
tooltipDelay: 300,
items: () => [],
});
const $route = useRoute();
const $emit = defineEmits<{
(event: 'select', itemId: string);
(event: 'update:modelValue', itemId: string);
}>();
const activeTab = ref(props.modelValue);
const upperMenuItems = computed(() =>
props.items.filter((item: IMenuItem) => item.position === 'top' && item.available !== false),
);
const lowerMenuItems = computed(() =>
props.items.filter((item: IMenuItem) => item.position === 'bottom' && item.available !== false),
);
const currentRoute = computed(() => {
return $route ?? { name: '', path: '' };
});
onMounted(() => {
if (props.mode === 'router') {
const found = props.items.find((item) =>
doesMenuItemMatchCurrentRoute(item, currentRoute.value),
);
activeTab.value = found ? found.id : '';
} else {
activeTab.value = props.items.length > 0 ? props.items[0].id : '';
}
$emit('update:modelValue', activeTab.value);
});
const onSelect = (item: IMenuItem): void => {
if (props.mode === 'tabs') {
activeTab.value = item.id;
}
$emit('select', item.id);
$emit('update:modelValue', item.id);
};
</script>
<style lang="scss" module>

View file

@ -52,7 +52,7 @@
}"
data-test-id="menu-item"
:index="item.id"
@click="handleSelect(item)"
@click="handleSelect?.(item)"
>
<N8nIcon
v-if="item.icon"
@ -80,94 +80,67 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { computed, useCssModule } from 'vue';
import { useRoute } from 'vue-router';
import { ElSubMenu, ElMenuItem } from 'element-plus';
import N8nTooltip from '../N8nTooltip';
import N8nIcon from '../N8nIcon';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import ConditionalRouterLink from '../ConditionalRouterLink';
import type { IMenuItem, RouteObject } from '../../types';
import type { IMenuItem } from '../../types';
import { doesMenuItemMatchCurrentRoute } from './routerUtil';
export default defineComponent({
name: 'N8nMenuItem',
components: {
ElSubMenu,
ElMenuItem,
N8nIcon,
N8nTooltip,
ConditionalRouterLink,
},
props: {
item: {
type: Object as PropType<IMenuItem>,
required: true,
},
compact: {
type: Boolean,
default: false,
},
tooltipDelay: {
type: Number,
default: 300,
},
popperClass: {
type: String,
default: '',
},
mode: {
type: String,
default: 'router',
validator: (value: string): boolean => ['router', 'tabs'].includes(value),
},
activeTab: {
type: String,
default: undefined,
},
handleSelect: {
type: Function as PropType<(item: IMenuItem) => void>,
default: undefined,
},
},
computed: {
availableChildren(): IMenuItem[] {
return Array.isArray(this.item.children)
? this.item.children.filter((child) => child.available !== false)
: [];
},
currentRoute(): RouteObject {
return (
this.$route || {
name: '',
path: '',
}
);
},
submenuPopperClass(): string {
const popperClass = [this.$style.submenuPopper, this.popperClass];
if (this.compact) {
popperClass.push(this.$style.compact);
}
return popperClass.join(' ');
},
},
methods: {
isItemActive(item: IMenuItem): boolean {
const isItemActive = this.isActive(item);
const hasActiveChild =
Array.isArray(item.children) && item.children.some((child) => this.isActive(child));
return isItemActive || hasActiveChild;
},
isActive(item: IMenuItem): boolean {
if (this.mode === 'router') {
return doesMenuItemMatchCurrentRoute(item, this.currentRoute);
} else {
return item.id === this.activeTab;
}
},
},
interface MenuItemProps {
item: IMenuItem;
compact?: boolean;
tooltipDelay?: number;
popperClass?: string;
mode?: 'router' | 'tabs';
activeTab?: string;
handleSelect?: (item: IMenuItem) => void;
}
const props = withDefaults(defineProps<MenuItemProps>(), {
compact: false,
tooltipDelay: 300,
popperClass: '',
mode: 'router',
});
const $style = useCssModule();
const $route = useRoute();
const availableChildren = computed((): IMenuItem[] =>
Array.isArray(props.item.children)
? props.item.children.filter((child) => child.available !== false)
: [],
);
const currentRoute = computed(() => {
return $route ?? { name: '', path: '' };
});
const submenuPopperClass = computed((): string => {
const popperClass = [$style.submenuPopper, props.popperClass];
if (props.compact) {
popperClass.push($style.compact);
}
return popperClass.join(' ');
});
const isActive = (item: IMenuItem): boolean => {
if (props.mode === 'router') {
return doesMenuItemMatchCurrentRoute(item, currentRoute.value);
} else {
return item.id === props.activeTab;
}
};
const isItemActive = (item: IMenuItem): boolean => {
const hasActiveChild =
Array.isArray(item.children) && item.children.some((child) => isActive(child));
return isActive(item) || hasActiveChild;
};
</script>
<style module lang="scss">

View file

@ -1,10 +1,13 @@
import type { IMenuItem, RouteObject } from '@/types';
import type { RouteLocationRaw } from 'vue-router';
import type { IMenuItem } from '@/types';
import type { RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router';
/**
* Checks if the given menu item matches the current route.
*/
export function doesMenuItemMatchCurrentRoute(item: IMenuItem, currentRoute: RouteObject) {
export function doesMenuItemMatchCurrentRoute(
item: IMenuItem,
currentRoute: RouteLocationNormalizedLoaded,
) {
let activateOnRouteNames: string[] = [];
if (Array.isArray(item.activateOnRouteNames)) {
activateOnRouteNames = item.activateOnRouteNames;
@ -20,7 +23,7 @@ export function doesMenuItemMatchCurrentRoute(item: IMenuItem, currentRoute: Rou
}
return (
activateOnRouteNames.includes(currentRoute.name ?? '') ||
activateOnRouteNames.includes((currentRoute.name as string) ?? '') ||
activateOnRoutePaths.includes(currentRoute.path)
);
}

View file

@ -35,99 +35,75 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { computed } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { defineComponent, type PropType } from 'vue';
import type { Placement } from 'element-plus';
import N8nTooltip from '../N8nTooltip';
export default defineComponent({
name: 'N8nNodeIcon',
components: {
N8nTooltip,
FontAwesomeIcon,
},
props: {
type: {
type: String,
required: true,
validator: (value: string): boolean => ['file', 'icon', 'unknown'].includes(value),
},
src: {
type: String,
},
name: {
type: String,
},
nodeTypeName: {
type: String,
},
size: {
type: Number,
},
disabled: {
type: Boolean,
},
circle: {
type: Boolean,
},
color: {
type: String,
},
showTooltip: {
type: Boolean,
},
tooltipPosition: {
type: String as PropType<Placement>,
default: 'top',
},
badge: { type: Object as PropType<{ src: string; type: string }> },
},
computed: {
iconStyleData(): Record<string, string> {
if (!this.size) {
return {
color: this.color || '',
};
}
interface NodeIconProps {
type: 'file' | 'icon' | 'unknown';
src?: string;
name?: string;
nodeTypeName?: string;
size?: number;
disabled?: boolean;
circle?: boolean;
color?: string;
showTooltip?: boolean;
tooltipPosition?: Placement;
badge?: { src: string; type: string };
}
return {
color: this.color || '',
width: `${this.size}px`,
height: `${this.size}px`,
'font-size': `${this.size}px`,
'line-height': `${this.size}px`,
};
},
badgeSize(): number {
switch (this.size) {
case 40:
return 18;
case 24:
return 10;
case 18:
default:
return 8;
}
},
badgeStyleData(): Record<string, string> {
const size = this.badgeSize;
return {
padding: `${Math.floor(size / 4)}px`,
right: `-${Math.floor(size / 2)}px`,
bottom: `-${Math.floor(size / 2)}px`,
};
},
fontStyleData(): Record<string, string> {
if (!this.size) {
return {};
}
const props = withDefaults(defineProps<NodeIconProps>(), {
tooltipPosition: 'top',
});
return {
'max-width': `${this.size}px`,
};
},
},
const iconStyleData = computed((): Record<string, string> => {
if (!props.size) {
return {
color: props.color || '',
};
}
return {
color: props.color || '',
width: `${props.size}px`,
height: `${props.size}px`,
'font-size': `${props.size}px`,
'line-height': `${props.size}px`,
};
});
const badgeSize = computed((): number => {
switch (props.size) {
case 40:
return 18;
case 24:
return 10;
case 18:
default:
return 8;
}
});
const fontStyleData = computed((): Record<string, string> => {
if (!props.size) {
return {};
}
return {
'max-width': `${props.size}px`,
};
});
const badgeStyleData = computed((): Record<string, string> => {
const size = badgeSize.value;
return {
padding: `${Math.floor(size / 4)}px`,
right: `-${Math.floor(size / 2)}px`,
bottom: `-${Math.floor(size / 2)}px`,
};
});
</script>

View file

@ -58,18 +58,17 @@ Sanitized.args = {
'<script>alert(1)</script> This content contains a script tag and is <strong>sanitized</strong>.',
};
export const Truncated = PropTemplate.bind({});
Truncated.args = {
export const FullContent = PropTemplate.bind({});
FullContent.args = {
theme: 'warning',
truncate: true,
content:
'This content is long and will be truncated at 150 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
content: 'This is just the summary. <a data-key="toggle-expand">Show more</a>',
fullContent:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod ut labore et dolore magna aliqua. <a data-key="show-less">Show less</a>',
};
export const HtmlEdgeCase = PropTemplate.bind({});
HtmlEdgeCase.args = {
theme: 'warning',
truncate: true,
content:
'This content is long and will be truncated at 150 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod <a href="">read the documentation</a> ut labore et dolore magna aliqua.',
};

View file

@ -7,7 +7,7 @@
:id="`${id}-content`"
:class="showFullContent ? $style['expanded'] : $style['truncated']"
role="region"
v-html="sanitizeHtml(showFullContent ? fullContent : content)"
v-html="displayContent"
/>
</slot>
</N8nText>
@ -15,89 +15,70 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import sanitizeHtml from 'sanitize-html';
<script lang="ts" setup>
import { computed, ref, useCssModule } from 'vue';
import sanitize from 'sanitize-html';
import N8nText from '../../components/N8nText';
import Locale from '../../mixins/locale';
import { uid } from '../../utils';
export default defineComponent({
name: 'N8nNotice',
directives: {},
components: {
N8nText,
},
mixins: [Locale],
props: {
id: {
type: String,
default: () => uid('notice'),
},
theme: {
type: String,
default: 'warning',
},
content: {
type: String,
default: '',
},
fullContent: {
type: String,
default: '',
},
},
data() {
return {
showFullContent: false,
};
},
computed: {
classes(): string[] {
return ['notice', this.$style.notice, this.$style[this.theme]];
},
canTruncate(): boolean {
return this.fullContent !== undefined;
},
},
methods: {
toggleExpanded() {
this.showFullContent = !this.showFullContent;
},
sanitizeHtml(text: string): string {
return sanitizeHtml(text, {
allowedAttributes: {
a: [
'data-key',
'href',
'target',
'data-action',
'data-action-parameter-connectiontype',
'data-action-parameter-creatorview',
],
},
});
},
onClick(event: MouseEvent) {
if (!(event.target instanceof HTMLElement)) return;
interface NoticeProps {
id?: string;
theme?: 'success' | 'warning' | 'danger' | 'info';
content?: string;
fullContent?: string;
}
if (event.target.localName !== 'a') return;
if (event.target.dataset?.key) {
event.stopPropagation();
event.preventDefault();
if (event.target.dataset.key === 'show-less') {
this.showFullContent = false;
} else if (this.canTruncate && event.target.dataset.key === 'toggle-expand') {
this.showFullContent = !this.showFullContent;
} else {
this.$emit('action', event.target.dataset.key);
}
}
},
},
const props = withDefaults(defineProps<NoticeProps>(), {
id: () => uid('notice'),
theme: 'warning',
content: '',
fullContent: '',
});
const $emit = defineEmits<{
(event: 'action', key: string): void;
}>();
const $style = useCssModule();
const classes = computed(() => ['notice', $style.notice, $style[props.theme]]);
const canTruncate = computed(() => props.fullContent !== undefined);
const showFullContent = ref(false);
const displayContent = computed(() =>
sanitize(showFullContent.value ? props.fullContent : props.content, {
allowedAttributes: {
a: [
'data-key',
'href',
'target',
'data-action',
'data-action-parameter-connectiontype',
'data-action-parameter-creatorview',
],
},
}),
);
const onClick = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) return;
if (event.target.localName !== 'a') return;
const anchorKey = event.target.dataset?.key;
if (anchorKey) {
event.stopPropagation();
event.preventDefault();
if (anchorKey === 'show-less') {
showFullContent.value = false;
} else if (canTruncate.value && anchorKey === 'toggle-expand') {
showFullContent.value = !showFullContent.value;
} else {
$emit('action', anchorKey);
}
}
};
</script>
<style lang="scss" module>

View file

@ -8,12 +8,8 @@
</div>
</template>
<script>
import { defineComponent } from 'vue';
export default defineComponent({
name: 'N8nPulse',
});
<script lang="ts" setup>
defineOptions({ name: 'N8nPulse' });
</script>
<style lang="scss" module>

View file

@ -7,7 +7,7 @@
[$style.container]: true,
[$style.hoverable]: !disabled,
}"
aria-checked="true"
:aria-checked="active"
>
<div
:class="{
@ -23,33 +23,19 @@
</label>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
interface RadioButtonProps {
label: string;
value: string;
active?: boolean;
disabled?: boolean;
size?: 'small' | 'medium';
}
export default defineComponent({
name: 'N8nRadioButton',
props: {
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
active: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'medium',
validator: (value: string): boolean => ['small', 'medium'].includes(value),
},
disabled: {
type: Boolean,
},
},
withDefaults(defineProps<RadioButtonProps>(), {
active: false,
disabled: false,
size: 'medium',
});
</script>

View file

@ -54,3 +54,19 @@ Example.args = {
},
],
};
export const Disabled = Template.bind({});
Disabled.args = {
modelValue: 'enabled',
options: [
{
label: 'Enabled',
value: 'enabled',
},
{
label: 'Disabled',
value: 'disabled',
disabled: true,
},
],
};

View file

@ -15,48 +15,41 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import RadioButton from './RadioButton.vue';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
export interface RadioOption {
interface RadioOption {
label: string;
value: string;
disabled?: boolean;
}
export default defineComponent({
name: 'N8nRadioButtons',
components: {
RadioButton,
},
props: {
modelValue: {
type: String,
},
options: {
type: Array as PropType<RadioOption[]>,
default: (): RadioOption[] => [],
},
size: {
type: String,
},
disabled: {
type: Boolean,
},
},
emits: ['update:modelValue'],
methods: {
onClick(option: { label: string; value: string; disabled?: boolean }, event: MouseEvent) {
if (this.disabled || option.disabled) {
return;
}
this.$emit('update:modelValue', option.value, event);
},
},
interface RadioButtonsProps {
modelValue?: string;
options?: RadioOption[];
size: 'small' | 'medium';
disabled?: boolean;
}
const props = withDefaults(defineProps<RadioButtonsProps>(), {
active: false,
disabled: false,
size: 'medium',
});
const $emit = defineEmits<{
(event: 'update:modelValue', value: string, e: MouseEvent): void;
}>();
const onClick = (
option: { label: string; value: string; disabled?: boolean },
event: MouseEvent,
) => {
if (props.disabled || option.disabled) {
return;
}
$emit('update:modelValue', option.value, event);
};
</script>
<style lang="scss" module>

View file

@ -1,6 +1,6 @@
import type { ComponentInstance } from 'vue';
import type { StoryFn } from '@storybook/vue3';
import N8nRecycleScroller from './RecycleScroller.vue';
import type { ComponentInstance } from 'vue';
export default {
title: 'Atoms/RecycleScroller',
@ -23,7 +23,9 @@ const Template: StoryFn = (args) => ({
},
methods: {
resizeItem(item: { id: string; height: string }, fn: (item: { id: string }) => void) {
const itemRef = (this as ComponentInstance).$refs[`item-${item.id}`] as HTMLElement;
const itemRef = (this as ComponentInstance<typeof N8nRecycleScroller>).$refs[
`item-${item.id}`
] as HTMLElement;
item.height = '200px';
itemRef.style.height = '200px';

View file

@ -1,225 +1,182 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-use-before-define */
<script lang="ts" setup>
import type { ComponentPublicInstance } from 'vue';
import { computed, onMounted, onBeforeMount, ref, nextTick, watch } from 'vue';
import type { PropType, ComponentPublicInstance } from 'vue';
import { computed, defineComponent, onMounted, onBeforeMount, ref, nextTick, watch } from 'vue';
interface RecycleScrollerProps {
itemSize: number;
items: Array<Record<string, string>>;
itemKey: string;
offset?: number;
}
export default defineComponent({
name: 'N8nRecycleScroller',
props: {
itemSize: {
type: Number,
required: true,
},
items: {
type: Array as PropType<Array<Record<string, string>>>,
required: true,
},
itemKey: {
type: String,
required: true,
},
offset: {
type: Number,
default: 2,
},
},
setup(props) {
const wrapperRef = ref<HTMLElement | null>(null);
const scrollerRef = ref<HTMLElement | null>(null);
const itemsRef = ref<HTMLElement | null>(null);
const itemRefs = ref<Record<string, Element | ComponentPublicInstance | null>>({});
const props = withDefaults(defineProps<RecycleScrollerProps>(), {
offset: 2,
});
const scrollTop = ref(0);
const wrapperHeight = ref(0);
const windowHeight = ref(0);
const wrapperRef = ref<HTMLElement | null>(null);
const scrollerRef = ref<HTMLElement | null>(null);
const itemsRef = ref<HTMLElement | null>(null);
const itemRefs = ref<Record<string, Element | ComponentPublicInstance | null>>({});
const itemCount = computed(() => props.items.length);
const scrollTop = ref(0);
const wrapperHeight = ref(0);
const windowHeight = ref(0);
/**
* Cache
*/
/** Cache */
const itemSizeCache = ref<Record<string, number>>({});
const itemPositionCache = computed(() => {
return props.items.reduce<Record<string, number>>((acc, item, index) => {
const key = item[props.itemKey];
const prevItem = props.items[index - 1];
const prevItemPosition = prevItem ? acc[prevItem[props.itemKey]] : 0;
const prevItemSize = prevItem ? itemSizeCache.value[prevItem[props.itemKey]] : 0;
const itemSizeCache = ref<Record<string, number>>({});
const itemPositionCache = computed(() => {
return props.items.reduce<Record<string, number>>((acc, item, index) => {
const key = item[props.itemKey];
const prevItem = props.items[index - 1];
const prevItemPosition = prevItem ? acc[prevItem[props.itemKey]] : 0;
const prevItemSize = prevItem ? itemSizeCache.value[prevItem[props.itemKey]] : 0;
acc[key] = prevItemPosition + prevItemSize;
acc[key] = prevItemPosition + prevItemSize;
return acc;
}, {});
});
return acc;
}, {});
});
/**
* Indexes
*/
/** Indexes */
const startIndex = computed(() => {
const foundIndex =
props.items.findIndex((item) => {
const itemPosition = itemPositionCache.value[item[props.itemKey]];
const startIndex = computed(() => {
const foundIndex =
props.items.findIndex((item) => {
const itemPosition = itemPositionCache.value[item[props.itemKey]];
return itemPosition >= scrollTop.value;
}) - 1;
const index = foundIndex - props.offset;
return itemPosition >= scrollTop.value;
}) - 1;
const index = foundIndex - props.offset;
return index < 0 ? 0 : index;
});
return index < 0 ? 0 : index;
});
const endIndex = computed(() => {
const foundIndex = props.items.findIndex((item) => {
const itemPosition = itemPositionCache.value[item[props.itemKey]];
const itemSize = itemSizeCache.value[item[props.itemKey]];
const endIndex = computed(() => {
const foundIndex = props.items.findIndex((item) => {
const itemPosition = itemPositionCache.value[item[props.itemKey]];
const itemSize = itemSizeCache.value[item[props.itemKey]];
return itemPosition + itemSize >= scrollTop.value + wrapperHeight.value;
});
const index = foundIndex + props.offset;
return itemPosition + itemSize >= scrollTop.value + wrapperHeight.value;
});
const index = foundIndex + props.offset;
return foundIndex === -1 ? props.items.length - 1 : index;
});
return foundIndex === -1 ? props.items.length - 1 : index;
});
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value + 1);
});
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value + 1);
});
watch(
() => visibleItems.value,
(currentValue, previousValue) => {
const difference = currentValue.filter(
(currentItem) =>
!previousValue.find(
(previousItem) => previousItem[props.itemKey] === currentItem[props.itemKey],
),
);
if (difference.length > 0) {
updateItemSizeCache(difference);
}
},
watch(
() => visibleItems.value,
(currentValue, previousValue) => {
const difference = currentValue.filter(
(currentItem) =>
!previousValue.find(
(previousItem) => previousItem[props.itemKey] === currentItem[props.itemKey],
),
);
/**
* Computed sizes and styles
*/
const scrollerHeight = computed(() => {
const lastItem = props.items[props.items.length - 1];
const lastItemPosition = lastItem ? itemPositionCache.value[lastItem[props.itemKey]] : 0;
const lastItemSize = lastItem ? itemSizeCache.value[lastItem[props.itemKey]] : props.itemSize;
return lastItemPosition + lastItemSize;
});
const scrollerStyles = computed(() => ({
height: `${scrollerHeight.value}px`,
}));
const itemsStyles = computed(() => {
const offset = itemPositionCache.value[props.items[startIndex.value][props.itemKey]];
return {
transform: `translateY(${offset}px)`,
};
});
/**
* Lifecycle hooks
*/
onBeforeMount(() => {
initializeItemSizeCache();
});
onMounted(() => {
if (wrapperRef.value) {
wrapperRef.value.addEventListener('scroll', onScroll);
updateItemSizeCache(visibleItems.value);
}
window.addEventListener('resize', onWindowResize);
onWindowResize();
});
/**
* Event handlers
*/
function initializeItemSizeCache() {
props.items.forEach((item) => {
itemSizeCache.value = {
...itemSizeCache.value,
[item[props.itemKey]]: props.itemSize,
};
});
if (difference.length > 0) {
updateItemSizeCache(difference);
}
},
);
function updateItemSizeCache(items: Array<Record<string, string>>) {
for (const item of items) {
onUpdateItemSize(item);
}
}
/** Computed sizes and styles */
function onUpdateItemSize(item: { [key: string]: string }) {
void nextTick(() => {
const itemId = item[props.itemKey];
const itemRef = itemRefs.value[itemId] as HTMLElement;
const previousSize = itemSizeCache.value[itemId];
const size = itemRef ? itemRef.offsetHeight : props.itemSize;
const difference = size - previousSize;
const scrollerHeight = computed(() => {
const lastItem = props.items[props.items.length - 1];
const lastItemPosition = lastItem ? itemPositionCache.value[lastItem[props.itemKey]] : 0;
const lastItemSize = lastItem ? itemSizeCache.value[lastItem[props.itemKey]] : props.itemSize;
itemSizeCache.value = {
...itemSizeCache.value,
[item[props.itemKey]]: size,
};
return lastItemPosition + lastItemSize;
});
if (wrapperRef.value && scrollTop.value) {
wrapperRef.value.scrollTop = wrapperRef.value.scrollTop + difference;
scrollTop.value = wrapperRef.value.scrollTop;
}
});
}
const scrollerStyles = computed(() => ({
height: `${scrollerHeight.value}px`,
}));
function onWindowResize() {
if (wrapperRef.value) {
wrapperHeight.value = wrapperRef.value.offsetHeight;
void nextTick(() => {
updateItemSizeCache(visibleItems.value);
});
}
const itemsStyles = computed(() => {
const offset = itemPositionCache.value[props.items[startIndex.value][props.itemKey]];
windowHeight.value = window.innerHeight;
}
return {
transform: `translateY(${offset}px)`,
};
});
function onScroll() {
if (!wrapperRef.value) {
return;
}
/** Lifecycle hooks */
onBeforeMount(() => {
initializeItemSizeCache();
});
onMounted(() => {
if (wrapperRef.value) {
wrapperRef.value.addEventListener('scroll', onScroll);
updateItemSizeCache(visibleItems.value);
}
window.addEventListener('resize', onWindowResize);
onWindowResize();
});
/** Event handlers */
function initializeItemSizeCache() {
props.items.forEach((item) => {
itemSizeCache.value = {
...itemSizeCache.value,
[item[props.itemKey]]: props.itemSize,
};
});
}
function updateItemSizeCache(items: Array<Record<string, string>>) {
for (const item of items) {
onUpdateItemSize(item);
}
}
function onUpdateItemSize(item: { [key: string]: string }) {
void nextTick(() => {
const itemId = item[props.itemKey];
const itemRef = itemRefs.value[itemId] as HTMLElement;
const previousSize = itemSizeCache.value[itemId];
const size = itemRef ? itemRef.offsetHeight : props.itemSize;
const difference = size - previousSize;
itemSizeCache.value = {
...itemSizeCache.value,
[item[props.itemKey]]: size,
};
if (wrapperRef.value && scrollTop.value) {
wrapperRef.value.scrollTop = wrapperRef.value.scrollTop + difference;
scrollTop.value = wrapperRef.value.scrollTop;
}
});
}
return {
startIndex,
endIndex,
itemCount,
itemSizeCache,
itemPositionCache,
itemsVisible: visibleItems,
itemsStyles,
scrollerStyles,
scrollerScrollTop: scrollTop,
scrollerRef,
wrapperRef,
itemsRef,
itemRefs,
onUpdateItemSize,
};
},
});
function onWindowResize() {
if (wrapperRef.value) {
wrapperHeight.value = wrapperRef.value.offsetHeight;
void nextTick(() => {
updateItemSizeCache(visibleItems.value);
});
}
windowHeight.value = window.innerHeight;
}
function onScroll() {
if (!wrapperRef.value) {
return;
}
scrollTop.value = wrapperRef.value.scrollTop;
}
</script>
<template>
@ -227,7 +184,7 @@ export default defineComponent({
<div ref="scrollerRef" class="recycle-scroller" :style="scrollerStyles">
<div ref="itemsRef" class="recycle-scroller-items-wrapper" :style="itemsStyles">
<div
v-for="item in itemsVisible"
v-for="item in visibleItems"
:key="item[itemKey]"
:ref="(element) => (itemRefs[item[itemKey]] = element)"
class="recycle-scroller-item"

View file

@ -3,7 +3,7 @@ import N8nRecycleScroller from '../RecycleScroller.vue';
const itemSize = 100;
const itemKey = 'id';
const items = [...(new Array(100) as number[])].map((item, index) => ({
const items = [...(new Array(100) as number[])].map((_, index) => ({
id: index,
name: `Item ${index}`,
}));

View file

@ -11,9 +11,8 @@
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed, ref } from 'vue';
function closestNumber(value: number, divisor: number): number {
const q = value / divisor;
@ -35,7 +34,7 @@ function getSize(min: number, virtual: number, gridSize: number): number {
return min;
}
const directionsCursorMaps: { [key: string]: string } = {
const directionsCursorMaps = {
right: 'ew-resize',
top: 'ns-resize',
bottom: 'ns-resize',
@ -44,141 +43,141 @@ const directionsCursorMaps: { [key: string]: string } = {
topRight: 'ne-resize',
bottomLeft: 'sw-resize',
bottomRight: 'se-resize',
} as const;
type Direction = keyof typeof directionsCursorMaps;
interface ResizeProps {
isResizingEnabled?: boolean;
height?: number;
width?: number;
minHeight?: number;
minWidth?: number;
scale?: number;
gridSize?: number;
supportedDirections?: Direction[];
}
const props = withDefaults(defineProps<ResizeProps>(), {
isResizingEnabled: true,
height: 0,
width: 0,
minHeight: 0,
minWidth: 0,
scale: 1,
gridSize: 20,
supportedDirections: () => [],
});
export interface ResizeData {
height: number;
width: number;
dX: number;
dY: number;
x: number;
y: number;
direction: Direction;
}
const $emit = defineEmits<{
(event: 'resizestart');
(event: 'resize', value: ResizeData): void;
(event: 'resizeend');
}>();
const enabledDirections = computed((): Direction[] => {
const availableDirections = Object.keys(directionsCursorMaps) as Direction[];
if (!props.isResizingEnabled) return [];
if (props.supportedDirections.length === 0) return availableDirections;
return props.supportedDirections;
});
const state = {
dir: ref<Direction | ''>(''),
dHeight: ref(0),
dWidth: ref(0),
vHeight: ref(0),
vWidth: ref(0),
x: ref(0),
y: ref(0),
};
export default defineComponent({
name: 'N8nResize',
props: {
isResizingEnabled: {
type: Boolean,
default: true,
},
height: {
type: Number,
default: 0,
},
width: {
type: Number,
default: 0,
},
minHeight: {
type: Number,
default: 0,
},
minWidth: {
type: Number,
default: 0,
},
scale: {
type: Number,
default: 1,
},
gridSize: {
type: Number,
default: 20,
},
supportedDirections: {
type: Array as PropType<string[]>,
default: (): string[] => [],
},
},
data() {
return {
directionsCursorMaps,
dir: '',
dHeight: 0,
dWidth: 0,
vHeight: 0,
vWidth: 0,
x: 0,
y: 0,
};
},
computed: {
enabledDirections(): string[] {
const availableDirections = Object.keys(directionsCursorMaps);
const mouseMove = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
let dWidth = 0;
let dHeight = 0;
let top = false;
let left = false;
if (!this.isResizingEnabled) return [];
if (this.supportedDirections.length === 0) return availableDirections;
if (state.dir.value.includes('right')) {
dWidth = event.pageX - state.x.value;
}
if (state.dir.value.includes('left')) {
dWidth = state.x.value - event.pageX;
left = true;
}
if (state.dir.value.includes('top')) {
dHeight = state.y.value - event.pageY;
top = true;
}
if (state.dir.value.includes('bottom')) {
dHeight = event.pageY - state.y.value;
}
return this.supportedDirections;
},
},
methods: {
resizerMove(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
const deltaWidth = (dWidth - state.dWidth.value) / props.scale;
const deltaHeight = (dHeight - state.dHeight.value) / props.scale;
const targetResizer = event.target as { dataset: { dir: string } } | null;
if (targetResizer) {
this.dir = targetResizer.dataset.dir.toLocaleLowerCase();
}
state.vHeight.value = state.vHeight.value + deltaHeight;
state.vWidth.value = state.vWidth.value + deltaWidth;
const height = getSize(props.minHeight, state.vHeight.value, props.gridSize);
const width = getSize(props.minWidth, state.vWidth.value, props.gridSize);
document.body.style.cursor = directionsCursorMaps[this.dir];
const dX = left && width !== props.width ? -1 * (width - props.width) : 0;
const dY = top && height !== props.height ? -1 * (height - props.height) : 0;
const x = event.x;
const y = event.y;
const direction = state.dir.value as Direction;
this.x = event.pageX;
this.y = event.pageY;
this.dWidth = 0;
this.dHeight = 0;
this.vHeight = this.height;
this.vWidth = this.width;
$emit('resize', { height, width, dX, dY, x, y, direction });
state.dHeight.value = dHeight;
state.dWidth.value = dWidth;
};
window.addEventListener('mousemove', this.mouseMove);
window.addEventListener('mouseup', this.mouseUp);
this.$emit('resizestart');
},
mouseMove(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
let dWidth = 0;
let dHeight = 0;
let top = false;
let left = false;
const mouseUp = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
$emit('resizeend');
window.removeEventListener('mousemove', mouseMove);
window.removeEventListener('mouseup', mouseUp);
document.body.style.cursor = 'unset';
state.dir.value = '';
};
if (this.dir.includes('right')) {
dWidth = event.pageX - this.x;
}
if (this.dir.includes('left')) {
dWidth = this.x - event.pageX;
left = true;
}
if (this.dir.includes('top')) {
dHeight = this.y - event.pageY;
top = true;
}
if (this.dir.includes('bottom')) {
dHeight = event.pageY - this.y;
}
const resizerMove = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const deltaWidth = (dWidth - this.dWidth) / this.scale;
const deltaHeight = (dHeight - this.dHeight) / this.scale;
const targetResizer = event.target as { dataset: { dir: string } } | null;
if (targetResizer) {
state.dir.value = targetResizer.dataset.dir.toLocaleLowerCase() as Direction;
}
this.vHeight = this.vHeight + deltaHeight;
this.vWidth = this.vWidth + deltaWidth;
const height = getSize(this.minHeight, this.vHeight, this.gridSize);
const width = getSize(this.minWidth, this.vWidth, this.gridSize);
document.body.style.cursor = directionsCursorMaps[state.dir.value];
const dX = left && width !== this.width ? -1 * (width - this.width) : 0;
const dY = top && height !== this.height ? -1 * (height - this.height) : 0;
const x = event.x;
const y = event.y;
const direction = this.dir;
state.x.value = event.pageX;
state.y.value = event.pageY;
state.dWidth.value = 0;
state.dHeight.value = 0;
state.vHeight.value = props.height;
state.vWidth.value = props.width;
this.$emit('resize', { height, width, dX, dY, x, y, direction });
this.dHeight = dHeight;
this.dWidth = dWidth;
},
mouseUp(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
this.$emit('resizeend');
window.removeEventListener('mousemove', this.mouseMove);
window.removeEventListener('mouseup', this.mouseUp);
document.body.style.cursor = 'unset';
this.dir = '';
},
},
});
window.addEventListener('mousemove', mouseMove);
window.addEventListener('mouseup', mouseUp);
$emit('resizestart');
};
</script>
<style lang="scss" module>

View file

@ -9,12 +9,10 @@
<script lang="ts" setup>
import { computed } from 'vue';
// TODO: replace `object` with a more detailed type
export type RouteTo = string | object;
import { type RouteLocationRaw } from 'vue-router';
interface RouteProps {
to?: RouteTo;
to?: RouteLocationRaw;
newWindow?: boolean;
}

View file

@ -1,3 +1,2 @@
import N8nRoute from './Route.vue';
export type { RouteTo } from './Route.vue';
export default N8nRoute;

View file

@ -30,16 +30,11 @@
<script lang="ts">
import { ElSelect } from 'element-plus';
import { defineComponent } from 'vue';
import { type PropType, defineComponent } from 'vue';
import type { SelectSize } from '@/types';
type InnerSelectRef = InstanceType<typeof ElSelect>;
export interface IProps {
size?: string;
limitPopperWidth?: string;
popperClass?: string;
}
export default defineComponent({
name: 'N8nSelect',
components: {
@ -49,10 +44,8 @@ export default defineComponent({
...ElSelect.props,
modelValue: {},
size: {
type: String,
type: String as PropType<SelectSize>,
default: 'large',
validator: (value: string): boolean =>
['mini', 'small', 'medium', 'large', 'xlarge'].includes(value),
},
placeholder: {
type: String,
@ -101,7 +94,7 @@ export default defineComponent({
return acc;
}, {});
},
computedSize(): string | undefined {
computedSize(): InnerSelectRef['$props']['size'] {
if (this.size === 'medium') {
return 'default';
}

View file

@ -57,150 +57,120 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import N8nInput from '../N8nInput';
import N8nMarkdown from '../N8nMarkdown';
import N8nResizeWrapper from '../N8nResizeWrapper';
import N8nResizeWrapper, { type ResizeData } from '../N8nResizeWrapper/ResizeWrapper.vue';
import N8nText from '../N8nText';
import Locale from '../../mixins/locale';
import { defineComponent } from 'vue';
import { useI18n } from '../../composables/useI18n';
export default defineComponent({
name: 'N8nSticky',
components: {
N8nInput,
N8nMarkdown,
N8nResizeWrapper,
N8nText,
},
mixins: [Locale],
props: {
modelValue: {
type: String,
},
height: {
type: Number,
default: 180,
},
width: {
type: Number,
default: 240,
},
minHeight: {
type: Number,
default: 80,
},
minWidth: {
type: Number,
default: 150,
},
scale: {
type: Number,
default: 1,
},
gridSize: {
type: Number,
default: 20,
},
id: {
type: String,
default: '0',
},
defaultText: {
type: String,
},
editMode: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
backgroundColor: {
value: [Number, String],
default: 1,
},
},
data() {
return {
isResizing: false,
};
},
computed: {
resHeight(): number {
if (this.height < this.minHeight) {
return this.minHeight;
}
return this.height;
},
resWidth(): number {
if (this.width < this.minWidth) {
return this.minWidth;
}
return this.width;
},
styles(): { height: string; width: string } {
const styles: { height: string; width: string } = {
height: `${this.resHeight}px`,
width: `${this.resWidth}px`,
};
interface StickyProps {
modelValue?: string;
height?: number;
width?: number;
minHeight?: number;
minWidth?: number;
scale?: number;
gridSize?: number;
id?: string;
defaultText?: string;
editMode?: boolean;
readOnly?: boolean;
backgroundColor?: number | string;
}
return styles;
},
shouldShowFooter(): boolean {
return this.resHeight > 100 && this.resWidth > 155;
},
},
watch: {
editMode(newMode, prevMode) {
setTimeout(() => {
if (newMode && !prevMode && this.$refs.input) {
const textarea = this.$refs.input as HTMLTextAreaElement;
if (this.defaultText === this.modelValue) {
textarea.select();
}
textarea.focus();
}
}, 100);
},
},
methods: {
onDoubleClick() {
if (!this.readOnly) {
this.$emit('edit', true);
}
},
onInputBlur() {
if (!this.isResizing) {
this.$emit('edit', false);
}
},
onUpdateModelValue(value: string) {
this.$emit('update:modelValue', value);
},
onMarkdownClick(link: string, event: Event) {
this.$emit('markdown-click', link, event);
},
onResize(values: unknown[]) {
this.$emit('resize', values);
},
onResizeEnd(resizeEnd: unknown) {
this.isResizing = false;
this.$emit('resizeend', resizeEnd);
},
onResizeStart() {
this.isResizing = true;
this.$emit('resizestart');
},
onInputScroll(event: WheelEvent) {
// Pass through zoom events but hold regular scrolling
if (!event.ctrlKey && !event.metaKey) {
event.stopPropagation();
}
},
},
const props = withDefaults(defineProps<StickyProps>(), {
height: 180,
width: 240,
minHeight: 80,
minWidth: 150,
scale: 1,
gridSize: 20,
id: '0',
editMode: false,
readOnly: false,
backgroundColor: 1,
});
const $emit = defineEmits<{
(event: 'edit', editing: boolean);
(event: 'update:modelValue', value: string);
(event: 'markdown-click', link: string, e: Event);
(event: 'resize', values: ResizeData);
(event: 'resizestart');
(event: 'resizeend', value: unknown);
}>();
const { t } = useI18n();
const isResizing = ref(false);
const input = ref<HTMLTextAreaElement | undefined>(undefined);
const resHeight = computed((): number => {
return props.height < props.minHeight ? props.minHeight : props.height;
});
const resWidth = computed((): number => {
return props.width < props.minWidth ? props.minWidth : props.width;
});
const styles = computed((): { height: string; width: string } => ({
height: `${resHeight.value}px`,
width: `${resWidth.value}px`,
}));
const shouldShowFooter = computed((): boolean => resHeight.value > 100 && resWidth.value > 155);
watch(
() => props.editMode,
(newMode, prevMode) => {
setTimeout(() => {
if (newMode && !prevMode && input.value) {
if (props.defaultText === props.modelValue) {
input.value.select();
}
input.value.focus();
}
}, 100);
},
);
const onDoubleClick = () => {
if (!props.readOnly) $emit('edit', true);
};
const onInputBlur = () => {
if (!isResizing.value) $emit('edit', false);
};
const onUpdateModelValue = (value: string) => {
$emit('update:modelValue', value);
};
const onMarkdownClick = (link: string, event: Event) => {
$emit('markdown-click', link, event);
};
const onResize = (values: ResizeData) => {
$emit('resize', values);
};
const onResizeStart = () => {
isResizing.value = true;
$emit('resizestart');
};
const onResizeEnd = (resizeEnd: unknown) => {
isResizing.value = false;
$emit('resizeend', resizeEnd);
};
const onInputScroll = (event: WheelEvent) => {
// Pass through zoom events but hold regular scrolling
if (!event.ctrlKey && !event.metaKey) {
event.stopPropagation();
}
};
</script>
<style lang="scss" module>

View file

@ -36,8 +36,12 @@ export const Example = Template.bind({});
Example.args = {
options: [
{
label: 'Test',
value: 'test',
label: 'First',
value: 'first',
},
{
label: 'Second',
value: 'second',
},
{
label: 'Github',

View file

@ -47,12 +47,11 @@
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue';
import N8nIcon from '../N8nIcon';
export interface N8nTabOptions {
interface TabOptions {
value: string;
label?: string;
icon?: string;
@ -61,85 +60,63 @@ export interface N8nTabOptions {
align?: 'left' | 'right';
}
export default defineComponent({
name: 'N8nTabs',
components: {
N8nIcon,
},
props: {
modelValue: {
type: String,
default: '',
},
options: {
type: Array as PropType<N8nTabOptions[]>,
default: (): N8nTabOptions[] => [],
},
},
data() {
return {
scrollPosition: 0,
canScrollRight: false,
resizeObserver: null as ResizeObserver | null,
};
},
mounted() {
const container = this.$refs.tabs as HTMLDivElement | undefined;
if (container) {
container.addEventListener('scroll', (event: Event) => {
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
this.scrollPosition = (event.target as Element).scrollLeft;
interface TabsProps {
modelValue?: string;
options?: TabOptions[];
}
this.canScrollRight = scrollWidth - width > this.scrollPosition;
});
this.resizeObserver = new ResizeObserver(() => {
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
this.canScrollRight = scrollWidth - width > this.scrollPosition;
});
this.resizeObserver.observe(container);
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
this.canScrollRight = scrollWidth - width > this.scrollPosition;
}
},
unmounted() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
},
methods: {
handleTooltipClick(tab: string, event: MouseEvent) {
this.$emit('tooltipClick', tab, event);
},
handleTabClick(tab: string) {
this.$emit('update:modelValue', tab);
},
scrollLeft() {
this.scroll(-50);
},
scrollRight() {
this.scroll(50);
},
scroll(left: number) {
const container = this.$refs.tabs as
| (HTMLDivElement & { scrollBy: ScrollByFunction })
| undefined;
if (container) {
container.scrollBy({ left, top: 0, behavior: 'smooth' });
}
},
},
withDefaults(defineProps<TabsProps>(), {
options: () => [],
});
type ScrollByFunction = (arg: {
left: number;
top: number;
behavior: 'smooth' | 'instant' | 'auto';
}) => void;
const scrollPosition = ref(0);
const canScrollRight = ref(false);
const tabs = ref<Element | undefined>(undefined);
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
const container = tabs.value as Element;
if (container) {
container.addEventListener('scroll', (event: Event) => {
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
scrollPosition.value = (event.target as Element).scrollLeft;
canScrollRight.value = scrollWidth - width > scrollPosition.value;
});
resizeObserver = new ResizeObserver(() => {
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
canScrollRight.value = scrollWidth - width > scrollPosition.value;
});
resizeObserver.observe(container);
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
canScrollRight.value = scrollWidth - width > scrollPosition.value;
}
});
onUnmounted(() => {
resizeObserver?.disconnect();
});
const $emit = defineEmits<{
(event: 'tooltipClick', tab: string, e: MouseEvent): void;
(event: 'update:modelValue', tab: string);
}>();
const handleTooltipClick = (tab: string, event: MouseEvent) => $emit('tooltipClick', tab, event);
const handleTabClick = (tab: string) => $emit('update:modelValue', tab);
const scroll = (left: number) => {
const container = tabs.value;
if (container) {
container.scrollBy({ left, top: 0, behavior: 'smooth' });
}
};
const scrollLeft = () => scroll(-50);
const scrollRight = () => scroll(50);
</script>
<style lang="scss" module>

View file

@ -19,7 +19,7 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import N8nTag from '../N8nTag';
import N8nLink from '../N8nLink';
import { useI18n } from '../../composables/useI18n';
@ -46,11 +46,11 @@ const $emit = defineEmits(['expand', 'click:tag']);
const { t } = useI18n();
let showAll = false;
const showAll = ref(false);
const visibleTags = computed((): ITag[] => {
const { tags, truncate, truncateAt } = props;
if (truncate && !showAll && tags.length > truncateAt) {
if (truncate && !showAll.value && tags.length > truncateAt) {
return tags.slice(0, truncateAt);
}
return tags;
@ -59,7 +59,7 @@ const visibleTags = computed((): ITag[] => {
const hiddenTagsLength = computed((): number => props.tags.length - props.truncateAt);
const onExpand = () => {
showAll = true;
showAll.value = true;
$emit('expand', true);
};
</script>

View file

@ -17,7 +17,7 @@
:value="value[label]"
:node-class="nodeClass"
>
<template v-for="(index, name) in $slots" #[name]="data">
<template v-for="(_, name) in $slots" #[name]="data">
<slot :name="name" v-bind="data"></slot>
</template>
</n8n-tree>
@ -26,60 +26,51 @@
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed, useCssModule } from 'vue';
export default defineComponent({
name: 'N8nTree',
components: {},
props: {
value: {
type: Object as PropType<Record<string, unknown>>,
default: () => ({}),
},
path: {
type: Array as PropType<string[]>,
default: () => [],
},
depth: {
type: Number,
default: 0,
},
nodeClass: {
type: String,
default: '',
},
},
computed: {
classes(): Record<string, boolean> {
return { [this.nodeClass]: !!this.nodeClass, [this.$style.indent]: this.depth > 0 };
},
},
methods: {
isSimple(data: unknown): boolean {
if (data === null || data === undefined) {
return true;
}
interface TreeProps {
value?: Record<string, unknown>;
path?: string[];
depth?: number;
nodeClass?: string;
}
if (typeof data === 'object' && Object.keys(data).length === 0) {
return true;
}
if (Array.isArray(data) && data.length === 0) {
return true;
}
return typeof data !== 'object';
},
getPath(key: string): unknown[] {
if (Array.isArray(this.value)) {
return [...this.path, parseInt(key, 10)];
}
return [...this.path, key];
},
},
defineOptions({ name: 'N8nTree' });
const props = withDefaults(defineProps<TreeProps>(), {
value: () => ({}),
path: () => [],
depth: 0,
nodeClass: '',
});
const $style = useCssModule();
const classes = computed((): Record<string, boolean> => {
return { [props.nodeClass]: !!props.nodeClass, [$style.indent]: props.depth > 0 };
});
const isSimple = (data: unknown): boolean => {
if (data === null || data === undefined) {
return true;
}
if (typeof data === 'object' && Object.keys(data).length === 0) {
return true;
}
if (Array.isArray(data) && data.length === 0) {
return true;
}
return typeof data !== 'object';
};
const getPath = (key: string): unknown[] => {
if (Array.isArray(props.value)) {
return [...props.path, parseInt(key, 10)];
}
return [...props.path, key];
};
</script>
<style lang="scss" module>

View file

@ -5,7 +5,7 @@
:model-value="modelValue"
:filterable="true"
:filter-method="setFilter"
:placeholder="placeholder"
:placeholder="placeholder || t('nds.userSelect.selectUser')"
:default-first-option="true"
teleported
:popper-class="$style.limitPopperWidth"
@ -30,114 +30,83 @@
</N8nSelect>
</template>
<script lang="ts">
<script lang="ts" setup>
import { computed, ref } from 'vue';
import N8nUserInfo from '../N8nUserInfo';
import N8nSelect from '../N8nSelect';
import N8nOption from '../N8nOption';
import type { IUser } from '../../types';
import Locale from '../../mixins/locale';
import { t } from '../../locale';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { useI18n } from '../../composables/useI18n';
import type { IUser, SelectSize } from '../../types';
export default defineComponent({
name: 'N8nUserSelect',
components: {
N8nUserInfo,
N8nSelect,
N8nOption,
},
mixins: [Locale],
props: {
users: {
type: Array as PropType<IUser[]>,
default: () => [],
},
modelValue: {
type: String,
default: '',
},
ignoreIds: {
type: Array as PropType<string[]>,
default: () => [],
validator: (ids: string[]) => !ids.find((id) => typeof id !== 'string'),
},
currentUserId: {
type: String,
default: '',
},
placeholder: {
type: String,
default: () => t('nds.userSelect.selectUser'),
},
size: {
type: String,
default: '',
validator: (value: string): boolean => ['mini', 'small', 'medium', 'large'].includes(value),
},
},
data() {
return {
filter: '',
};
},
computed: {
filteredUsers(): IUser[] {
return this.users.filter((user) => {
if (user.isPendingUser || !user.email) {
return false;
}
interface UserSelectProps {
users?: IUser[];
modelValue?: string;
ignoreIds?: string[];
currentUserId?: string;
placeholder?: string;
size?: Exclude<SelectSize, 'xlarge'>;
}
if (this.ignoreIds.includes(user.id)) {
return false;
}
if (user.fullName) {
const match = user.fullName.toLowerCase().includes(this.filter.toLowerCase());
if (match) {
return true;
}
}
return user.email.includes(this.filter);
});
},
sortedUsers(): IUser[] {
return [...this.filteredUsers].sort((a: IUser, b: IUser) => {
if (a.lastName && b.lastName && a.lastName !== b.lastName) {
return a.lastName > b.lastName ? 1 : -1;
}
if (a.firstName && b.firstName && a.firstName !== b.firstName) {
return a.firstName > b.firstName ? 1 : -1;
}
if (!a.email || !b.email) {
throw new Error('Expected all users to have email');
}
return a.email > b.email ? 1 : -1;
});
},
},
methods: {
setFilter(value: string) {
this.filter = value;
},
onBlur() {
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
getLabel(user: IUser) {
if (!user.fullName) {
return user.email;
}
return `${user.fullName} (${user.email})`;
},
},
const props = withDefaults(defineProps<UserSelectProps>(), {
users: () => [],
modelValue: '',
ignoreIds: () => [],
currentUserId: '',
});
const $emit = defineEmits(['blur', 'focus']);
const { t } = useI18n();
const filter = ref('');
const filteredUsers = computed(() =>
props.users.filter((user) => {
if (user.isPendingUser || !user.email) {
return false;
}
if (props.ignoreIds.includes(user.id)) {
return false;
}
if (user.fullName) {
const match = user.fullName.toLowerCase().includes(filter.value.toLowerCase());
if (match) {
return true;
}
}
return user.email.includes(filter.value);
}),
);
const sortedUsers = computed(() =>
[...filteredUsers.value].sort((a: IUser, b: IUser) => {
if (a.lastName && b.lastName && a.lastName !== b.lastName) {
return a.lastName > b.lastName ? 1 : -1;
}
if (a.firstName && b.firstName && a.firstName !== b.firstName) {
return a.firstName > b.firstName ? 1 : -1;
}
if (!a.email || !b.email) {
throw new Error('Expected all users to have email');
}
return a.email > b.email ? 1 : -1;
}),
);
const setFilter = (value: string) => {
filter.value = value;
};
const onBlur = () => $emit('blur');
const onFocus = () => $emit('focus');
const getLabel = (user: IUser) =>
!user.fullName ? user.email : `${user.fullName} (${user.email})`;
</script>
<style lang="scss" module>

View file

@ -23,8 +23,8 @@ export default function () {
args = {} as unknown as Array<string | object>;
}
return str.replace(RE_NARGS, (match, prefix, i, index: number) => {
let result;
return str.replace(RE_NARGS, (match, _, i, index: number) => {
let result: string | object | null;
if (str[index - 1] === '{' && str[index + match.length] === '}') {
return i;

View file

@ -15,61 +15,52 @@
</table>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue';
export default defineComponent({
name: 'VariableTable',
props: {
variables: {
type: Array as PropType<string[]>,
required: true,
},
attr: {
type: String,
default: '',
},
},
data() {
return {
observer: null as null | MutationObserver,
values: {} as Record<string, string>,
};
},
created() {
const setValues = () => {
this.variables.forEach((variable) => {
const style = getComputedStyle(document.body);
const value = style.getPropertyValue(variable);
interface VariableTableProps {
variables: string[];
attr?: string;
}
this.values = {
...this.values,
[variable]: value,
};
});
};
const props = withDefaults(defineProps<VariableTableProps>(), {
attr: '',
});
setValues();
let observer: MutationObserver | null = null;
let values: Record<string, string> = {};
// when theme class is added or removed, reset color values
this.observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
setValues();
}
}
onMounted(() => {
const setValues = () => {
props.variables.forEach((variable) => {
const style = getComputedStyle(document.body);
const value = style.getPropertyValue(variable);
values = {
...values,
[variable]: value,
};
});
const body = document.querySelector('body');
if (body) {
this.observer.observe(body, { attributes: true });
};
setValues();
// when theme class is added or removed, reset color values
observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
setValues();
}
}
},
unmounted() {
if (this.observer) {
this.observer.disconnect();
}
},
});
const body = document.querySelector('body');
if (body) {
observer.observe(body, { attributes: true });
}
});
onUnmounted(() => {
observer?.disconnect();
});
</script>

View file

@ -9,42 +9,37 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed } from 'vue';
export default defineComponent({
name: 'SpacingPreview',
props: {
property: {
type: String,
default: 'padding',
},
side: {
type: String,
default: '',
},
},
computed: {
sizes() {
return [
'0',
'5xs',
'4xs',
'3xs',
'2xs',
'xs',
's',
'm',
'l',
'xl',
'2xl',
'3xl',
'4xl',
'5xl',
].concat(this.property === 'margin' ? ['auto'] : []);
},
},
interface SpacingPreviewProps {
property?: 'padding' | 'margin';
side?: string;
}
const SIZES = [
'0',
'5xs',
'4xs',
'3xs',
'2xs',
'xs',
's',
'm',
'l',
'xl',
'2xl',
'3xl',
'4xl',
'5xl',
] as const;
const props = withDefaults(defineProps<SpacingPreviewProps>(), {
property: 'padding',
side: '',
});
const sizes = computed(() => [...SIZES, ...(props.property === 'margin' ? ['auto'] : [])]);
</script>
<style lang="scss">

View file

@ -4,7 +4,7 @@ export default {
title: 'Utilities/Lists',
};
const ListStyleNoneTemplate: StoryFn = (args, { argTypes }) => ({
const ListStyleNoneTemplate: StoryFn = (_, { argTypes }) => ({
props: Object.keys(argTypes),
template: `<ul class="list-style-none">
<li>List item 1</li>
@ -15,7 +15,7 @@ const ListStyleNoneTemplate: StoryFn = (args, { argTypes }) => ({
export const StyleNone = ListStyleNoneTemplate.bind({});
const ListInlineTemplate: StoryFn = (args, { argTypes }) => ({
const ListInlineTemplate: StoryFn = (_, { argTypes }) => ({
props: Object.keys(argTypes),
template: `<ul class="list-inline">
<li>List item 1</li>

View file

@ -3,6 +3,6 @@ export * from './datatable';
export * from './form';
export * from './i18n';
export * from './menu';
export * from './router';
export * from './select';
export * from './user';
export * from './keyboardshortcut';

View file

@ -8,7 +8,7 @@ export type IMenuItem = {
icon?: string;
secondaryIcon?: {
name: string;
size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge';
size?: 'xsmall' | 'small' | 'medium' | 'large';
tooltip?: Partial<ElTooltipProps>;
};
customIconSize?: 'medium' | 'small';

View file

@ -1,4 +0,0 @@
export interface RouteObject {
name: string;
path: string;
}

View file

@ -0,0 +1,2 @@
const SELECT_SIZES = ['mini', 'small', 'medium', 'large', 'xlarge'] as const;
export type SelectSize = (typeof SELECT_SIZES)[number];

View file

@ -1,11 +1,5 @@
import { createEventBus } from '../event-bus';
// @TODO: Remove when conflicting vitest and jest globals are reconciled
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const vi: (typeof import('vitest'))['vitest'];
}
describe('createEventBus()', () => {
const eventBus = createEventBus();

View file

@ -19,7 +19,6 @@
// TODO: remove all options below this line
"strict": false,
"noImplicitAny": false,
"noUnusedLocals": false,
"noImplicitReturns": false
},
"include": ["src/**/*.ts", "src/**/*.vue"]