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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@
v-html="htmlContent" v-html="htmlContent"
/> />
<div v-else :class="$style.markdown"> <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" /> <N8nLoading :loading="loading" :rows="loadingRows" animated variant="p" />
<div :class="$style.spacer" /> <div :class="$style.spacer" />
</div> </div>

View file

@ -53,104 +53,76 @@
</div> </div>
</template> </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 { ElMenu } from 'element-plus';
import N8nMenuItem from '../N8nMenuItem'; import N8nMenuItem from '../N8nMenuItem';
import type { PropType } from 'vue'; import type { IMenuItem } from '../../types';
import { defineComponent } from 'vue';
import type { IMenuItem, RouteObject } from '../../types';
import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil'; import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil';
export default defineComponent({ interface MenuProps {
name: 'N8nMenu', type?: 'primary' | 'secondary';
components: { defaultActive?: string;
ElMenu, collapsed?: boolean;
N8nMenuItem, transparentBackground?: boolean;
}, mode?: 'router' | 'tabs';
props: { tooltipDelay?: number;
type: { items?: IMenuItem[];
type: String, modelValue?: 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),
);
this.activeTab = found ? found.id : ''; const props = withDefaults(defineProps<MenuProps>(), {
} else { type: 'primary',
this.activeTab = this.items.length > 0 ? this.items[0].id : ''; collapsed: false,
} transparentBackground: false,
mode: 'router',
this.$emit('update:modelValue', this.activeTab); tooltipDelay: 300,
}, items: () => [],
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 $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> </script>
<style lang="scss" module> <style lang="scss" module>

View file

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

View file

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

View file

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

View file

@ -58,18 +58,17 @@ Sanitized.args = {
'<script>alert(1)</script> This content contains a script tag and is <strong>sanitized</strong>.', '<script>alert(1)</script> This content contains a script tag and is <strong>sanitized</strong>.',
}; };
export const Truncated = PropTemplate.bind({}); export const FullContent = PropTemplate.bind({});
Truncated.args = { FullContent.args = {
theme: 'warning', theme: 'warning',
truncate: true, content: 'This is just the summary. <a data-key="toggle-expand">Show more</a>',
content: fullContent:
'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.', '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({}); export const HtmlEdgeCase = PropTemplate.bind({});
HtmlEdgeCase.args = { HtmlEdgeCase.args = {
theme: 'warning', theme: 'warning',
truncate: true,
content: 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.', '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`" :id="`${id}-content`"
:class="showFullContent ? $style['expanded'] : $style['truncated']" :class="showFullContent ? $style['expanded'] : $style['truncated']"
role="region" role="region"
v-html="sanitizeHtml(showFullContent ? fullContent : content)" v-html="displayContent"
/> />
</slot> </slot>
</N8nText> </N8nText>
@ -15,57 +15,38 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed, ref, useCssModule } from 'vue';
import sanitizeHtml from 'sanitize-html'; import sanitize from 'sanitize-html';
import N8nText from '../../components/N8nText'; import N8nText from '../../components/N8nText';
import Locale from '../../mixins/locale';
import { uid } from '../../utils'; import { uid } from '../../utils';
export default defineComponent({ interface NoticeProps {
name: 'N8nNotice', id?: string;
directives: {}, theme?: 'success' | 'warning' | 'danger' | 'info';
components: { content?: string;
N8nText, fullContent?: string;
}, }
mixins: [Locale],
props: { const props = withDefaults(defineProps<NoticeProps>(), {
id: { id: () => uid('notice'),
type: String, theme: 'warning',
default: () => uid('notice'), content: '',
}, fullContent: '',
theme: { });
type: String,
default: 'warning', const $emit = defineEmits<{
}, (event: 'action', key: string): void;
content: { }>();
type: String,
default: '', const $style = useCssModule();
},
fullContent: { const classes = computed(() => ['notice', $style.notice, $style[props.theme]]);
type: String, const canTruncate = computed(() => props.fullContent !== undefined);
default: '',
}, const showFullContent = ref(false);
}, const displayContent = computed(() =>
data() { sanitize(showFullContent.value ? props.fullContent : props.content, {
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: { allowedAttributes: {
a: [ a: [
'data-key', 'data-key',
@ -76,28 +57,28 @@ export default defineComponent({
'data-action-parameter-creatorview', 'data-action-parameter-creatorview',
], ],
}, },
}); }),
}, );
onClick(event: MouseEvent) {
const onClick = (event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) return; if (!(event.target instanceof HTMLElement)) return;
if (event.target.localName !== 'a') return; if (event.target.localName !== 'a') return;
if (event.target.dataset?.key) { const anchorKey = event.target.dataset?.key;
if (anchorKey) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
if (event.target.dataset.key === 'show-less') { if (anchorKey === 'show-less') {
this.showFullContent = false; showFullContent.value = false;
} else if (this.canTruncate && event.target.dataset.key === 'toggle-expand') { } else if (canTruncate.value && anchorKey === 'toggle-expand') {
this.showFullContent = !this.showFullContent; showFullContent.value = !showFullContent.value;
} else { } else {
this.$emit('action', event.target.dataset.key); $emit('action', anchorKey);
} }
} }
}, };
},
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import type { ComponentInstance } from 'vue';
import type { StoryFn } from '@storybook/vue3'; import type { StoryFn } from '@storybook/vue3';
import N8nRecycleScroller from './RecycleScroller.vue'; import N8nRecycleScroller from './RecycleScroller.vue';
import type { ComponentInstance } from 'vue';
export default { export default {
title: 'Atoms/RecycleScroller', title: 'Atoms/RecycleScroller',
@ -23,7 +23,9 @@ const Template: StoryFn = (args) => ({
}, },
methods: { methods: {
resizeItem(item: { id: string; height: string }, fn: (item: { id: string }) => void) { 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'; item.height = '200px';
itemRef.style.height = '200px'; itemRef.style.height = '200px';

View file

@ -1,47 +1,31 @@
<script lang="ts"> <script lang="ts" setup>
/* eslint-disable @typescript-eslint/no-use-before-define */ import type { ComponentPublicInstance } from 'vue';
import { computed, onMounted, onBeforeMount, ref, nextTick, watch } from 'vue';
import type { PropType, ComponentPublicInstance } from 'vue'; interface RecycleScrollerProps {
import { computed, defineComponent, onMounted, onBeforeMount, ref, nextTick, watch } from 'vue'; itemSize: number;
items: Array<Record<string, string>>;
itemKey: string;
offset?: number;
}
export default defineComponent({ const props = withDefaults(defineProps<RecycleScrollerProps>(), {
name: 'N8nRecycleScroller', offset: 2,
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 scrollTop = ref(0); const wrapperRef = ref<HTMLElement | null>(null);
const wrapperHeight = ref(0); const scrollerRef = ref<HTMLElement | null>(null);
const windowHeight = ref(0); 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 itemSizeCache = ref<Record<string, number>>({});
const itemPositionCache = computed(() => { const itemPositionCache = computed(() => {
return props.items.reduce<Record<string, number>>((acc, item, index) => { return props.items.reduce<Record<string, number>>((acc, item, index) => {
const key = item[props.itemKey]; const key = item[props.itemKey];
const prevItem = props.items[index - 1]; const prevItem = props.items[index - 1];
@ -52,13 +36,11 @@ export default defineComponent({
return acc; return acc;
}, {}); }, {});
}); });
/** /** Indexes */
* Indexes
*/
const startIndex = computed(() => { const startIndex = computed(() => {
const foundIndex = const foundIndex =
props.items.findIndex((item) => { props.items.findIndex((item) => {
const itemPosition = itemPositionCache.value[item[props.itemKey]]; const itemPosition = itemPositionCache.value[item[props.itemKey]];
@ -68,9 +50,9 @@ export default defineComponent({
const index = foundIndex - props.offset; const index = foundIndex - props.offset;
return index < 0 ? 0 : index; return index < 0 ? 0 : index;
}); });
const endIndex = computed(() => { const endIndex = computed(() => {
const foundIndex = props.items.findIndex((item) => { const foundIndex = props.items.findIndex((item) => {
const itemPosition = itemPositionCache.value[item[props.itemKey]]; const itemPosition = itemPositionCache.value[item[props.itemKey]];
const itemSize = itemSizeCache.value[item[props.itemKey]]; const itemSize = itemSizeCache.value[item[props.itemKey]];
@ -80,13 +62,13 @@ export default defineComponent({
const index = foundIndex + props.offset; const index = foundIndex + props.offset;
return foundIndex === -1 ? props.items.length - 1 : index; return foundIndex === -1 ? props.items.length - 1 : index;
}); });
const visibleItems = computed(() => { const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value + 1); return props.items.slice(startIndex.value, endIndex.value + 1);
}); });
watch( watch(
() => visibleItems.value, () => visibleItems.value,
(currentValue, previousValue) => { (currentValue, previousValue) => {
const difference = currentValue.filter( const difference = currentValue.filter(
@ -100,41 +82,37 @@ export default defineComponent({
updateItemSizeCache(difference); 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 lastItem = props.items[props.items.length - 1];
const lastItemPosition = lastItem ? itemPositionCache.value[lastItem[props.itemKey]] : 0; const lastItemPosition = lastItem ? itemPositionCache.value[lastItem[props.itemKey]] : 0;
const lastItemSize = lastItem ? itemSizeCache.value[lastItem[props.itemKey]] : props.itemSize; const lastItemSize = lastItem ? itemSizeCache.value[lastItem[props.itemKey]] : props.itemSize;
return lastItemPosition + lastItemSize; return lastItemPosition + lastItemSize;
}); });
const scrollerStyles = computed(() => ({ const scrollerStyles = computed(() => ({
height: `${scrollerHeight.value}px`, height: `${scrollerHeight.value}px`,
})); }));
const itemsStyles = computed(() => { const itemsStyles = computed(() => {
const offset = itemPositionCache.value[props.items[startIndex.value][props.itemKey]]; const offset = itemPositionCache.value[props.items[startIndex.value][props.itemKey]];
return { return {
transform: `translateY(${offset}px)`, transform: `translateY(${offset}px)`,
}; };
}); });
/** /** Lifecycle hooks */
* Lifecycle hooks
*/
onBeforeMount(() => { onBeforeMount(() => {
initializeItemSizeCache(); initializeItemSizeCache();
}); });
onMounted(() => { onMounted(() => {
if (wrapperRef.value) { if (wrapperRef.value) {
wrapperRef.value.addEventListener('scroll', onScroll); wrapperRef.value.addEventListener('scroll', onScroll);
updateItemSizeCache(visibleItems.value); updateItemSizeCache(visibleItems.value);
@ -142,28 +120,26 @@ export default defineComponent({
window.addEventListener('resize', onWindowResize); window.addEventListener('resize', onWindowResize);
onWindowResize(); onWindowResize();
}); });
/** /** Event handlers */
* Event handlers
*/
function initializeItemSizeCache() { function initializeItemSizeCache() {
props.items.forEach((item) => { props.items.forEach((item) => {
itemSizeCache.value = { itemSizeCache.value = {
...itemSizeCache.value, ...itemSizeCache.value,
[item[props.itemKey]]: props.itemSize, [item[props.itemKey]]: props.itemSize,
}; };
}); });
} }
function updateItemSizeCache(items: Array<Record<string, string>>) { function updateItemSizeCache(items: Array<Record<string, string>>) {
for (const item of items) { for (const item of items) {
onUpdateItemSize(item); onUpdateItemSize(item);
} }
} }
function onUpdateItemSize(item: { [key: string]: string }) { function onUpdateItemSize(item: { [key: string]: string }) {
void nextTick(() => { void nextTick(() => {
const itemId = item[props.itemKey]; const itemId = item[props.itemKey];
const itemRef = itemRefs.value[itemId] as HTMLElement; const itemRef = itemRefs.value[itemId] as HTMLElement;
@ -181,9 +157,9 @@ export default defineComponent({
scrollTop.value = wrapperRef.value.scrollTop; scrollTop.value = wrapperRef.value.scrollTop;
} }
}); });
} }
function onWindowResize() { function onWindowResize() {
if (wrapperRef.value) { if (wrapperRef.value) {
wrapperHeight.value = wrapperRef.value.offsetHeight; wrapperHeight.value = wrapperRef.value.offsetHeight;
void nextTick(() => { void nextTick(() => {
@ -192,34 +168,15 @@ export default defineComponent({
} }
windowHeight.value = window.innerHeight; windowHeight.value = window.innerHeight;
} }
function onScroll() { function onScroll() {
if (!wrapperRef.value) { if (!wrapperRef.value) {
return; return;
} }
scrollTop.value = wrapperRef.value.scrollTop; scrollTop.value = wrapperRef.value.scrollTop;
} }
return {
startIndex,
endIndex,
itemCount,
itemSizeCache,
itemPositionCache,
itemsVisible: visibleItems,
itemsStyles,
scrollerStyles,
scrollerScrollTop: scrollTop,
scrollerRef,
wrapperRef,
itemsRef,
itemRefs,
onUpdateItemSize,
};
},
});
</script> </script>
<template> <template>
@ -227,7 +184,7 @@ export default defineComponent({
<div ref="scrollerRef" class="recycle-scroller" :style="scrollerStyles"> <div ref="scrollerRef" class="recycle-scroller" :style="scrollerStyles">
<div ref="itemsRef" class="recycle-scroller-items-wrapper" :style="itemsStyles"> <div ref="itemsRef" class="recycle-scroller-items-wrapper" :style="itemsStyles">
<div <div
v-for="item in itemsVisible" v-for="item in visibleItems"
:key="item[itemKey]" :key="item[itemKey]"
:ref="(element) => (itemRefs[item[itemKey]] = element)" :ref="(element) => (itemRefs[item[itemKey]] = element)"
class="recycle-scroller-item" class="recycle-scroller-item"

View file

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

View file

@ -11,9 +11,8 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import type { PropType } from 'vue'; import { computed, ref } from 'vue';
import { defineComponent } from 'vue';
function closestNumber(value: number, divisor: number): number { function closestNumber(value: number, divisor: number): number {
const q = value / divisor; const q = value / divisor;
@ -35,7 +34,7 @@ function getSize(min: number, virtual: number, gridSize: number): number {
return min; return min;
} }
const directionsCursorMaps: { [key: string]: string } = { const directionsCursorMaps = {
right: 'ew-resize', right: 'ew-resize',
top: 'ns-resize', top: 'ns-resize',
bottom: 'ns-resize', bottom: 'ns-resize',
@ -44,90 +43,68 @@ const directionsCursorMaps: { [key: string]: string } = {
topRight: 'ne-resize', topRight: 'ne-resize',
bottomLeft: 'sw-resize', bottomLeft: 'sw-resize',
bottomRight: 'se-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({ const mouseMove = (event: MouseEvent) => {
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) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
let dWidth = 0; let dWidth = 0;
@ -135,50 +112,72 @@ export default defineComponent({
let top = false; let top = false;
let left = false; let left = false;
if (this.dir.includes('right')) { if (state.dir.value.includes('right')) {
dWidth = event.pageX - this.x; dWidth = event.pageX - state.x.value;
} }
if (this.dir.includes('left')) { if (state.dir.value.includes('left')) {
dWidth = this.x - event.pageX; dWidth = state.x.value - event.pageX;
left = true; left = true;
} }
if (this.dir.includes('top')) { if (state.dir.value.includes('top')) {
dHeight = this.y - event.pageY; dHeight = state.y.value - event.pageY;
top = true; top = true;
} }
if (this.dir.includes('bottom')) { if (state.dir.value.includes('bottom')) {
dHeight = event.pageY - this.y; dHeight = event.pageY - state.y.value;
} }
const deltaWidth = (dWidth - this.dWidth) / this.scale; const deltaWidth = (dWidth - state.dWidth.value) / props.scale;
const deltaHeight = (dHeight - this.dHeight) / this.scale; const deltaHeight = (dHeight - state.dHeight.value) / props.scale;
this.vHeight = this.vHeight + deltaHeight; state.vHeight.value = state.vHeight.value + deltaHeight;
this.vWidth = this.vWidth + deltaWidth; state.vWidth.value = state.vWidth.value + deltaWidth;
const height = getSize(this.minHeight, this.vHeight, this.gridSize); const height = getSize(props.minHeight, state.vHeight.value, props.gridSize);
const width = getSize(this.minWidth, this.vWidth, this.gridSize); const width = getSize(props.minWidth, state.vWidth.value, props.gridSize);
const dX = left && width !== this.width ? -1 * (width - this.width) : 0; const dX = left && width !== props.width ? -1 * (width - props.width) : 0;
const dY = top && height !== this.height ? -1 * (height - this.height) : 0; const dY = top && height !== props.height ? -1 * (height - props.height) : 0;
const x = event.x; const x = event.x;
const y = event.y; 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 }); $emit('resize', { height, width, dX, dY, x, y, direction });
this.dHeight = dHeight; state.dHeight.value = dHeight;
this.dWidth = dWidth; state.dWidth.value = dWidth;
}, };
mouseUp(event: MouseEvent) {
const mouseUp = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.$emit('resizeend'); $emit('resizeend');
window.removeEventListener('mousemove', this.mouseMove); window.removeEventListener('mousemove', mouseMove);
window.removeEventListener('mouseup', this.mouseUp); window.removeEventListener('mouseup', mouseUp);
document.body.style.cursor = 'unset'; 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> </script>
<style lang="scss" module> <style lang="scss" module>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,24 +9,15 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed } from 'vue';
export default defineComponent({ interface SpacingPreviewProps {
name: 'SpacingPreview', property?: 'padding' | 'margin';
props: { side?: string;
property: { }
type: String,
default: 'padding', const SIZES = [
},
side: {
type: String,
default: '',
},
},
computed: {
sizes() {
return [
'0', '0',
'5xs', '5xs',
'4xs', '4xs',
@ -41,10 +32,14 @@ export default defineComponent({
'3xl', '3xl',
'4xl', '4xl',
'5xl', '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> </script>
<style lang="scss"> <style lang="scss">

View file

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

View file

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

View file

@ -8,7 +8,7 @@ export type IMenuItem = {
icon?: string; icon?: string;
secondaryIcon?: { secondaryIcon?: {
name: string; name: string;
size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'; size?: 'xsmall' | 'small' | 'medium' | 'large';
tooltip?: Partial<ElTooltipProps>; tooltip?: Partial<ElTooltipProps>;
}; };
customIconSize?: 'medium' | 'small'; 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'; 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()', () => { describe('createEventBus()', () => {
const eventBus = createEventBus(); const eventBus = createEventBus();

View file

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