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) => {
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);
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(' ');
},
},
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;
}
},
},
});
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,72 +35,48 @@
</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) {
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 };
}
const props = withDefaults(defineProps<NodeIconProps>(), {
tooltipPosition: 'top',
});
const iconStyleData = computed((): Record<string, string> => {
if (!props.size) {
return {
color: this.color || '',
color: props.color || '',
};
}
return {
color: this.color || '',
width: `${this.size}px`,
height: `${this.size}px`,
'font-size': `${this.size}px`,
'line-height': `${this.size}px`,
color: props.color || '',
width: `${props.size}px`,
height: `${props.size}px`,
'font-size': `${props.size}px`,
'line-height': `${props.size}px`,
};
},
badgeSize(): number {
switch (this.size) {
});
const badgeSize = computed((): number => {
switch (props.size) {
case 40:
return 18;
case 24:
@ -109,25 +85,25 @@ export default defineComponent({
default:
return 8;
}
},
badgeStyleData(): Record<string, string> {
const size = this.badgeSize;
});
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`,
};
},
fontStyleData(): Record<string, string> {
if (!this.size) {
return {};
}
return {
'max-width': `${this.size}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,57 +15,38 @@
</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, {
interface NoticeProps {
id?: string;
theme?: 'success' | 'warning' | 'danger' | 'info';
content?: string;
fullContent?: string;
}
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',
@ -76,28 +57,28 @@ export default defineComponent({
'data-action-parameter-creatorview',
],
},
});
},
onClick(event: MouseEvent) {
}),
);
const onClick = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) return;
if (event.target.localName !== 'a') return;
if (event.target.dataset?.key) {
const anchorKey = event.target.dataset?.key;
if (anchorKey) {
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;
if (anchorKey === 'show-less') {
showFullContent.value = false;
} else if (canTruncate.value && anchorKey === 'toggle-expand') {
showFullContent.value = !showFullContent.value;
} else {
this.$emit('action', event.target.dataset.key);
$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) {
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;
}
this.$emit('update:modelValue', option.value, event);
},
},
});
$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,47 +1,31 @@
<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(() => {
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];
@ -52,13 +36,11 @@ export default defineComponent({
return acc;
}, {});
});
});
/**
* Indexes
*/
/** Indexes */
const startIndex = computed(() => {
const startIndex = computed(() => {
const foundIndex =
props.items.findIndex((item) => {
const itemPosition = itemPositionCache.value[item[props.itemKey]];
@ -68,9 +50,9 @@ export default defineComponent({
const index = foundIndex - props.offset;
return index < 0 ? 0 : index;
});
});
const endIndex = computed(() => {
const endIndex = computed(() => {
const foundIndex = props.items.findIndex((item) => {
const itemPosition = itemPositionCache.value[item[props.itemKey]];
const itemSize = itemSizeCache.value[item[props.itemKey]];
@ -80,13 +62,13 @@ export default defineComponent({
const index = foundIndex + props.offset;
return foundIndex === -1 ? props.items.length - 1 : index;
});
});
const visibleItems = computed(() => {
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value + 1);
});
});
watch(
watch(
() => visibleItems.value,
(currentValue, previousValue) => {
const difference = currentValue.filter(
@ -100,41 +82,37 @@ export default defineComponent({
updateItemSizeCache(difference);
}
},
);
);
/**
* Computed sizes and styles
*/
/** Computed sizes and styles */
const scrollerHeight = computed(() => {
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(() => ({
const scrollerStyles = computed(() => ({
height: `${scrollerHeight.value}px`,
}));
}));
const itemsStyles = computed(() => {
const itemsStyles = computed(() => {
const offset = itemPositionCache.value[props.items[startIndex.value][props.itemKey]];
return {
transform: `translateY(${offset}px)`,
};
});
});
/**
* Lifecycle hooks
*/
/** Lifecycle hooks */
onBeforeMount(() => {
onBeforeMount(() => {
initializeItemSizeCache();
});
});
onMounted(() => {
onMounted(() => {
if (wrapperRef.value) {
wrapperRef.value.addEventListener('scroll', onScroll);
updateItemSizeCache(visibleItems.value);
@ -142,28 +120,26 @@ export default defineComponent({
window.addEventListener('resize', onWindowResize);
onWindowResize();
});
});
/**
* Event handlers
*/
/** Event handlers */
function initializeItemSizeCache() {
function initializeItemSizeCache() {
props.items.forEach((item) => {
itemSizeCache.value = {
...itemSizeCache.value,
[item[props.itemKey]]: props.itemSize,
};
});
}
}
function updateItemSizeCache(items: Array<Record<string, string>>) {
function updateItemSizeCache(items: Array<Record<string, string>>) {
for (const item of items) {
onUpdateItemSize(item);
}
}
}
function onUpdateItemSize(item: { [key: string]: string }) {
function onUpdateItemSize(item: { [key: string]: string }) {
void nextTick(() => {
const itemId = item[props.itemKey];
const itemRef = itemRefs.value[itemId] as HTMLElement;
@ -181,9 +157,9 @@ export default defineComponent({
scrollTop.value = wrapperRef.value.scrollTop;
}
});
}
}
function onWindowResize() {
function onWindowResize() {
if (wrapperRef.value) {
wrapperHeight.value = wrapperRef.value.offsetHeight;
void nextTick(() => {
@ -192,34 +168,15 @@ export default defineComponent({
}
windowHeight.value = window.innerHeight;
}
}
function onScroll() {
function onScroll() {
if (!wrapperRef.value) {
return;
}
scrollTop.value = wrapperRef.value.scrollTop;
}
return {
startIndex,
endIndex,
itemCount,
itemSizeCache,
itemPositionCache,
itemsVisible: visibleItems,
itemsStyles,
scrollerStyles,
scrollerScrollTop: scrollTop,
scrollerRef,
wrapperRef,
itemsRef,
itemRefs,
onUpdateItemSize,
};
},
});
}
</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,90 +43,68 @@ 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);
if (!this.isResizingEnabled) return [];
if (this.supportedDirections.length === 0) return availableDirections;
return this.supportedDirections;
},
},
methods: {
resizerMove(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
const targetResizer = event.target as { dataset: { dir: string } } | null;
if (targetResizer) {
this.dir = targetResizer.dataset.dir.toLocaleLowerCase();
}
document.body.style.cursor = directionsCursorMaps[this.dir];
this.x = event.pageX;
this.y = event.pageY;
this.dWidth = 0;
this.dHeight = 0;
this.vHeight = this.height;
this.vWidth = this.width;
window.addEventListener('mousemove', this.mouseMove);
window.addEventListener('mouseup', this.mouseUp);
this.$emit('resizestart');
},
mouseMove(event: MouseEvent) {
const mouseMove = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
let dWidth = 0;
@ -135,50 +112,72 @@ export default defineComponent({
let top = false;
let left = false;
if (this.dir.includes('right')) {
dWidth = event.pageX - this.x;
if (state.dir.value.includes('right')) {
dWidth = event.pageX - state.x.value;
}
if (this.dir.includes('left')) {
dWidth = this.x - event.pageX;
if (state.dir.value.includes('left')) {
dWidth = state.x.value - event.pageX;
left = true;
}
if (this.dir.includes('top')) {
dHeight = this.y - event.pageY;
if (state.dir.value.includes('top')) {
dHeight = state.y.value - event.pageY;
top = true;
}
if (this.dir.includes('bottom')) {
dHeight = event.pageY - this.y;
if (state.dir.value.includes('bottom')) {
dHeight = event.pageY - state.y.value;
}
const deltaWidth = (dWidth - this.dWidth) / this.scale;
const deltaHeight = (dHeight - this.dHeight) / this.scale;
const deltaWidth = (dWidth - state.dWidth.value) / props.scale;
const deltaHeight = (dHeight - state.dHeight.value) / props.scale;
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);
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);
const dX = left && width !== this.width ? -1 * (width - this.width) : 0;
const dY = top && height !== this.height ? -1 * (height - this.height) : 0;
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 = this.dir;
const direction = state.dir.value as Direction;
this.$emit('resize', { height, width, dX, dY, x, y, direction });
this.dHeight = dHeight;
this.dWidth = dWidth;
},
mouseUp(event: MouseEvent) {
$emit('resize', { height, width, dX, dY, x, y, direction });
state.dHeight.value = dHeight;
state.dWidth.value = dWidth;
};
const mouseUp = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.$emit('resizeend');
window.removeEventListener('mousemove', this.mouseMove);
window.removeEventListener('mouseup', this.mouseUp);
$emit('resizeend');
window.removeEventListener('mousemove', mouseMove);
window.removeEventListener('mouseup', mouseUp);
document.body.style.cursor = 'unset';
this.dir = '';
},
},
});
state.dir.value = '';
};
const resizerMove = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const targetResizer = event.target as { dataset: { dir: string } } | null;
if (targetResizer) {
state.dir.value = targetResizer.dataset.dir.toLocaleLowerCase() as Direction;
}
document.body.style.cursor = directionsCursorMaps[state.dir.value];
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;
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) {
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 && this.$refs.input) {
const textarea = this.$refs.input as HTMLTextAreaElement;
if (this.defaultText === this.modelValue) {
textarea.select();
if (newMode && !prevMode && input.value) {
if (props.defaultText === props.modelValue) {
input.value.select();
}
textarea.focus();
input.value.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) {
);
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;
interface TabsProps {
modelValue?: string;
options?: TabOptions[];
}
withDefaults(defineProps<TabsProps>(), {
options: () => [],
});
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;
this.scrollPosition = (event.target as Element).scrollLeft;
this.canScrollRight = scrollWidth - width > this.scrollPosition;
scrollPosition.value = (event.target as Element).scrollLeft;
canScrollRight.value = scrollWidth - width > scrollPosition.value;
});
this.resizeObserver = new ResizeObserver(() => {
resizeObserver = new ResizeObserver(() => {
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
this.canScrollRight = scrollWidth - width > this.scrollPosition;
canScrollRight.value = scrollWidth - width > scrollPosition.value;
});
this.resizeObserver.observe(container);
resizeObserver.observe(container);
const width = container.clientWidth;
const scrollWidth = container.scrollWidth;
this.canScrollRight = scrollWidth - width > this.scrollPosition;
canScrollRight.value = scrollWidth - width > scrollPosition.value;
}
},
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;
});
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' });
}
},
},
});
type ScrollByFunction = (arg: {
left: number;
top: number;
behavior: 'smooth' | 'instant' | 'auto';
}) => void;
};
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,38 +26,30 @@
</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 {
interface TreeProps {
value?: Record<string, unknown>;
path?: string[];
depth?: number;
nodeClass?: string;
}
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;
}
@ -71,15 +63,14 @@ export default defineComponent({
}
return typeof data !== 'object';
},
getPath(key: string): unknown[] {
if (Array.isArray(this.value)) {
return [...this.path, parseInt(key, 10)];
};
const getPath = (key: string): unknown[] => {
if (Array.isArray(props.value)) {
return [...props.path, parseInt(key, 10)];
}
return [...this.path, key];
},
},
});
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,80 +30,59 @@
</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) => {
interface UserSelectProps {
users?: IUser[];
modelValue?: string;
ignoreIds?: string[];
currentUserId?: string;
placeholder?: string;
size?: Exclude<SelectSize, 'xlarge'>;
}
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 (this.ignoreIds.includes(user.id)) {
if (props.ignoreIds.includes(user.id)) {
return false;
}
if (user.fullName) {
const match = user.fullName.toLowerCase().includes(this.filter.toLowerCase());
const match = user.fullName.toLowerCase().includes(filter.value.toLowerCase());
if (match) {
return true;
}
}
return user.email.includes(this.filter);
});
},
sortedUsers(): IUser[] {
return [...this.filteredUsers].sort((a: IUser, b: IUser) => {
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;
}
@ -116,28 +95,18 @@ export default defineComponent({
}
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 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,36 +15,29 @@
</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() {
interface VariableTableProps {
variables: string[];
attr?: string;
}
const props = withDefaults(defineProps<VariableTableProps>(), {
attr: '',
});
let observer: MutationObserver | null = null;
let values: Record<string, string> = {};
onMounted(() => {
const setValues = () => {
this.variables.forEach((variable) => {
props.variables.forEach((variable) => {
const style = getComputedStyle(document.body);
const value = style.getPropertyValue(variable);
this.values = {
...this.values,
values = {
...values,
[variable]: value,
};
});
@ -53,7 +46,7 @@ export default defineComponent({
setValues();
// when theme class is added or removed, reset color values
this.observer = new MutationObserver((mutationsList) => {
observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
setValues();
@ -62,14 +55,12 @@ export default defineComponent({
});
const body = document.querySelector('body');
if (body) {
this.observer.observe(body, { attributes: true });
observer.observe(body, { attributes: true });
}
},
unmounted() {
if (this.observer) {
this.observer.disconnect();
}
},
});
onUnmounted(() => {
observer?.disconnect();
});
</script>

View file

@ -9,24 +9,15 @@
</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 [
interface SpacingPreviewProps {
property?: 'padding' | 'margin';
side?: string;
}
const SIZES = [
'0',
'5xs',
'4xs',
@ -41,10 +32,14 @@ export default defineComponent({
'3xl',
'4xl',
'5xl',
].concat(this.property === 'margin' ? ['auto'] : []);
},
},
] 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"]