refactor(editor): Port more components over to composition API (no-changelog) (#8794)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-03-14 09:19:28 +01:00 committed by GitHub
parent edce632ee6
commit e2131b9ab6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1115 additions and 1630 deletions

View file

@ -35,48 +35,27 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import N8nButton from '../N8nButton'; import N8nButton from '../N8nButton';
import N8nHeading from '../N8nHeading'; import N8nHeading from '../N8nHeading';
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import N8nCallout from '../N8nCallout'; import N8nCallout, { type CalloutTheme } from '../N8nCallout';
import { defineComponent } from 'vue'; import type { ButtonType } from '@/types/button';
export default defineComponent({ interface ActionBoxProps {
name: 'N8nActionBox', emoji: string;
components: { heading: string;
N8nButton, buttonText: string;
N8nHeading, buttonType: ButtonType;
N8nText, description: string;
N8nCallout, calloutText: string;
}, calloutTheme: CalloutTheme;
props: { calloutIcon: string;
emoji: { }
type: String,
}, defineOptions({ name: 'N8nActionBox' });
heading: { withDefaults(defineProps<ActionBoxProps>(), {
type: String, calloutTheme: 'info',
},
buttonText: {
type: String,
},
buttonType: {
type: String,
},
description: {
type: String,
},
calloutText: {
type: String,
},
calloutTheme: {
type: String,
default: 'info',
},
calloutIcon: {
type: String,
},
},
}); });
</script> </script>

View file

@ -9,7 +9,7 @@ exports[`N8NActionBox > should render correctly 1`] = `
<div class="description"> <div class="description">
<n8n-text-stub color="text-base" bold="false" size="medium" compact="false" tag="span"></n8n-text-stub> <n8n-text-stub color="text-base" bold="false" size="medium" compact="false" tag="span"></n8n-text-stub>
</div> </div>
<n8n-button-stub label="Do something" type="primary" size="large" loading="false" disabled="false" outline="false" text="false" block="false" active="false" square="false" element="button"></n8n-button-stub> <n8n-button-stub block="false" element="button" label="Do something" square="false" active="false" disabled="false" loading="false" outline="false" size="large" text="false" type="primary"></n8n-button-stub>
<!--v-if--> <!--v-if-->
</div>" </div>"
`; `;

View file

@ -50,15 +50,21 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import type { PropType } from 'vue'; // This component is visually similar to the ActionToggle component
import { defineComponent } from 'vue'; // but it offers more options when it comes to dropdown items styling
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'; // (supports icons, separators, custom styling and all options provided
// by Element UI dropdown component).
// It can be used in different parts of editor UI while ActionToggle
// is designed to be used in card components.
import { ref, useCssModule, useAttrs } from 'vue';
import { ElDropdown, ElDropdownMenu, ElDropdownItem, type Placement } from 'element-plus';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
import { N8nKeyboardShortcut } from '../N8nKeyboardShortcut'; import { N8nKeyboardShortcut } from '../N8nKeyboardShortcut';
import type { KeyboardShortcut } from '../../types'; import type { KeyboardShortcut } from '../../types';
import type { IconSize } from '@/types/icon';
export interface IActionDropdownItem { interface IActionDropdownItem {
id: string; id: string;
label: string; label: string;
icon?: string; icon?: string;
@ -68,92 +74,56 @@ export interface IActionDropdownItem {
customClass?: string; customClass?: string;
} }
// This component is visually similar to the ActionToggle component const TRIGGER = ['click', 'hover'] as const;
// but it offers more options when it comes to dropdown items styling
// (supports icons, separators, custom styling and all options provided
// by Element UI dropdown component).
// It can be used in different parts of editor UI while ActionToggle
// is designed to be used in card components.
export default defineComponent({
name: 'N8nActionDropdown',
components: {
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
N8nIcon,
N8nKeyboardShortcut,
},
props: {
items: {
type: Array as PropType<IActionDropdownItem[]>,
required: true,
},
placement: {
type: String,
default: 'bottom',
validator: (value: string): boolean =>
['top', 'top-end', 'top-start', 'bottom', 'bottom-end', 'bottom-start'].includes(value),
},
activatorIcon: {
type: String,
default: 'ellipsis-h',
},
activatorSize: {
type: String,
default: 'medium',
},
iconSize: {
type: String,
default: 'medium',
validator: (value: string): boolean => ['small', 'medium', 'large'].includes(value),
},
trigger: {
type: String,
default: 'click',
validator: (value: string): boolean => ['click', 'hover'].includes(value),
},
hideArrow: {
type: Boolean,
default: false,
},
},
data() {
const testIdPrefix = this.$attrs['data-test-id'];
return { testIdPrefix };
},
methods: {
getItemClasses(item: IActionDropdownItem): Record<string, boolean> {
return {
[this.$style.itemContainer]: true,
[this.$style.disabled]: item.disabled,
[this.$style.hasCustomStyling]: item.customClass !== undefined,
...(item.customClass !== undefined ? { [item.customClass]: true } : {}),
};
},
onSelect(action: string): void {
this.$emit('select', action);
},
onVisibleChange(open: boolean): void {
this.$emit('visibleChange', open);
},
onButtonBlur(event: FocusEvent): void {
const elementDropdown = this.$refs.elementDropdown as InstanceType<typeof ElDropdown>;
// Hide dropdown when clicking outside of current document interface ActionDropdownProps {
if (elementDropdown?.handleClose && event.relatedTarget === null) { items: IActionDropdownItem[];
elementDropdown.handleClose(); placement?: Placement;
} activatorIcon?: string;
}, activatorSize?: IconSize;
open() { iconSize?: IconSize;
const elementDropdown = this.$refs.elementDropdown as InstanceType<typeof ElDropdown>; trigger?: (typeof TRIGGER)[number];
elementDropdown.handleOpen(); hideArrow?: boolean;
}, }
close() {
const elementDropdown = this.$refs.elementDropdown as InstanceType<typeof ElDropdown>; withDefaults(defineProps<ActionDropdownProps>(), {
elementDropdown.handleClose(); placement: 'bottom',
}, activatorIcon: 'ellipsis-h',
}, activatorSize: 'medium',
iconSize: 'medium',
trigger: 'click',
hideArrow: false,
}); });
const $attrs = useAttrs();
const testIdPrefix = $attrs['data-test-id'];
const $style = useCssModule();
const getItemClasses = (item: IActionDropdownItem): Record<string, boolean> => {
return {
[$style.itemContainer]: true,
[$style.disabled]: !!item.disabled,
[$style.hasCustomStyling]: item.customClass !== undefined,
...(item.customClass !== undefined ? { [item.customClass]: true } : {}),
};
};
const $emit = defineEmits(['select', 'visibleChange']);
const elementDropdown = ref<InstanceType<typeof ElDropdown>>();
const onSelect = (action: string) => $emit('select', action);
const onVisibleChange = (open: boolean) => $emit('visibleChange', open);
const onButtonBlur = (event: FocusEvent) => {
// Hide dropdown when clicking outside of current document
if (elementDropdown.value?.handleClose && event.relatedTarget === null) {
elementDropdown.value.handleClose();
}
};
const open = () => elementDropdown.value?.handleOpen();
const close = () => elementDropdown.value?.handleClose();
defineExpose({ open, close });
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -41,60 +41,36 @@
</span> </span>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import type { PropType } from 'vue'; import { ElDropdown, ElDropdownMenu, ElDropdownItem, type Placement } from 'element-plus';
import { defineComponent } from 'vue';
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus';
import N8nIcon from '../N8nIcon';
import type { UserAction } from '@/types'; import type { UserAction } from '@/types';
import N8nIcon from '../N8nIcon';
import type { IconOrientation, IconSize } from '@/types/icon';
export default defineComponent({ const SIZE = ['mini', 'small', 'medium'] as const;
name: 'N8nActionToggle', const THEME = ['default', 'dark'] as const;
components: {
ElDropdown, interface ActionToggleProps {
ElDropdownMenu, actions?: UserAction[];
ElDropdownItem, placement?: Placement;
N8nIcon, size?: (typeof SIZE)[number];
}, iconSize?: IconSize;
props: { theme?: (typeof THEME)[number];
actions: { iconOrientation?: IconOrientation;
type: Array as PropType<UserAction[]>, }
default: () => [],
}, defineOptions({ name: 'N8nActionToggle' });
placement: { withDefaults(defineProps<ActionToggleProps>(), {
type: String, actions: () => [],
default: 'bottom', placement: 'bottom',
validator: (value: string): boolean => size: 'medium',
['top', 'top-end', 'top-start', 'bottom', 'bottom-end', 'bottom-start'].includes(value), theme: 'default',
}, iconOrientation: 'vertical',
size: {
type: String,
default: 'medium',
validator: (value: string): boolean => ['mini', 'small', 'medium'].includes(value),
},
iconSize: {
type: String,
},
theme: {
type: String,
default: 'default',
validator: (value: string): boolean => ['default', 'dark'].includes(value),
},
iconOrientation: {
type: String,
default: 'vertical',
validator: (value: string): boolean => ['horizontal', 'vertical'].includes(value),
},
},
methods: {
onCommand(value: string) {
this.$emit('action', value);
},
onVisibleChange(value: boolean) {
this.$emit('visible-change', value);
},
},
}); });
const $emit = defineEmits(['action', 'visible-change']);
const onCommand = (value: string) => $emit('action', value);
const onVisibleChange = (value: boolean) => $emit('visible-change', value);
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -57,20 +57,20 @@ const icon = computed(() => {
} }
}); });
const style = useCssModule(); const $style = useCssModule();
const alertBoxClassNames = computed(() => { const alertBoxClassNames = computed(() => {
const classNames = ['n8n-alert', style.alert]; const classNames = ['n8n-alert', $style.alert];
if (props.type) { if (props.type) {
classNames.push(style[props.type]); classNames.push($style[props.type]);
} }
if (props.effect) { if (props.effect) {
classNames.push(style[props.effect]); classNames.push($style[props.effect]);
} }
if (props.center) { if (props.center) {
classNames.push(style.center); classNames.push($style.center);
} }
if (props.background) { if (props.background) {
classNames.push(style.background); classNames.push($style.background);
} }
return classNames; return classNames;
}); });

View file

@ -12,63 +12,48 @@
</span> </span>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed } from 'vue';
import Avatar from 'vue-boring-avatars'; import Avatar from 'vue-boring-avatars';
interface AvatarProps {
firstName: string;
lastName: string;
size: string;
colors: string[];
}
defineOptions({ name: 'N8nAvatar' });
const props = withDefaults(defineProps<AvatarProps>(), {
firstName: '',
lastName: '',
size: 'medium',
colors: () => [
'--color-primary',
'--color-secondary',
'--color-avatar-accent-1',
'--color-avatar-accent-2',
'--color-primary-tint-1',
],
});
const initials = computed(
() =>
(props.firstName ? props.firstName.charAt(0) : '') +
(props.lastName ? props.lastName.charAt(0) : ''),
);
const getColors = (colors: string[]): string[] => {
const style = getComputedStyle(document.body);
return colors.map((color: string) => style.getPropertyValue(color));
};
const sizes: { [size: string]: number } = { const sizes: { [size: string]: number } = {
small: 28, small: 28,
large: 48, large: 48,
medium: 40, medium: 40,
}; };
const getSize = (size: string): number => sizes[size];
import { defineComponent } from 'vue';
export default defineComponent({
name: 'N8nAvatar',
components: {
Avatar,
},
props: {
firstName: {
type: String,
default: '',
},
lastName: {
type: String,
default: '',
},
size: {
type: String,
default: 'medium',
},
colors: {
default: () => [
'--color-primary',
'--color-secondary',
'--color-avatar-accent-1',
'--color-avatar-accent-2',
'--color-primary-tint-1',
],
},
},
computed: {
initials() {
return (
(this.firstName ? this.firstName.charAt(0) : '') +
(this.lastName ? this.lastName.charAt(0) : '')
);
},
},
methods: {
getColors(colors: string[]): string[] {
const style = getComputedStyle(document.body);
return colors.map((color: string) => style.getPropertyValue(color));
},
getSize(size: string): number {
return sizes[size];
},
},
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -6,33 +6,31 @@
</span> </span>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import type { TextSize } from '@/types/text';
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import { defineComponent } from 'vue'; const THEME = [
'default',
'success',
'warning',
'danger',
'primary',
'secondary',
'tertiary',
] as const;
export default defineComponent({ interface BadgeProps {
components: { theme?: (typeof THEME)[number];
N8nText, size?: TextSize;
}, bold: boolean;
props: { }
theme: {
type: String, defineOptions({ name: 'N8nBadge' });
default: 'default', withDefaults(defineProps<BadgeProps>(), {
validator: (value: string) => theme: 'default',
['default', 'success', 'warning', 'danger', 'primary', 'secondary', 'tertiary'].includes( size: 'small',
value, bold: false,
),
},
size: {
type: String,
default: 'small',
},
bold: {
type: Boolean,
default: false,
},
},
}); });
</script> </script>

View file

@ -20,69 +20,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCssModule, computed, useAttrs, watchEffect } from 'vue';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
import N8nSpinner from '../N8nSpinner'; import N8nSpinner from '../N8nSpinner';
import { useCssModule, computed, useAttrs, watchEffect } from 'vue'; import type { ButtonProps } from '@/types/button';
const $style = useCssModule(); const $style = useCssModule();
const $attrs = useAttrs(); const $attrs = useAttrs();
const props = defineProps({ defineOptions({ name: 'N8nButton' });
label: { const props = withDefaults(defineProps<ButtonProps>(), {
type: String, label: '',
default: '', type: 'primary',
}, size: 'medium',
type: { loading: false,
type: String, disabled: false,
default: 'primary', outline: false,
}, text: false,
size: { block: false,
type: String, active: false,
default: 'medium', square: false,
}, element: 'button',
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
text: {
type: Boolean,
default: false,
},
icon: {
type: [String, Array],
},
block: {
type: Boolean,
default: false,
},
active: {
type: Boolean,
default: false,
},
float: {
type: String,
},
square: {
type: Boolean,
default: false,
},
element: {
type: String,
default: 'button',
validator: (value: string) => ['button', 'a'].includes(value),
},
href: {
type: String,
required: false,
},
}); });
watchEffect(() => { watchEffect(() => {

View file

@ -15,72 +15,57 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed, useCssModule } from 'vue';
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
const CALLOUT_DEFAULT_ICONS: { [key: string]: string } = { const THEMES = ['info', 'success', 'secondary', 'warning', 'danger', 'custom'] as const;
export type CalloutTheme = (typeof THEMES)[number];
const CALLOUT_DEFAULT_ICONS = {
info: 'info-circle', info: 'info-circle',
success: 'check-circle', success: 'check-circle',
warning: 'exclamation-triangle', warning: 'exclamation-triangle',
danger: 'exclamation-triangle', danger: 'exclamation-triangle',
}; };
export default defineComponent({ interface CalloutProps {
name: 'N8nCallout', theme: CalloutTheme;
components: { icon?: string;
N8nText, iconSize?: string;
N8nIcon, iconless?: boolean;
}, slim?: boolean;
props: { roundCorners?: boolean;
theme: { }
type: String,
required: true, defineOptions({ name: 'N8nCallout' });
validator: (value: string): boolean => const props = withDefaults(defineProps<CalloutProps>(), {
['info', 'success', 'secondary', 'warning', 'danger', 'custom'].includes(value), iconSize: 'medium',
}, roundCorners: true,
icon: { });
type: String,
}, const $style = useCssModule();
iconSize: { const classes = computed(() => [
type: String, 'n8n-callout',
default: 'medium', $style.callout,
}, $style[props.theme],
iconless: { props.slim ? $style.slim : '',
type: Boolean, props.roundCorners ? $style.round : '',
}, ]);
slim: {
type: Boolean, const getIcon = computed(
}, () => props.icon ?? CALLOUT_DEFAULT_ICONS?.[props.theme] ?? CALLOUT_DEFAULT_ICONS.info,
roundCorners: { );
type: Boolean,
default: true, const getIconSize = computed(() => {
}, if (props.iconSize) {
}, return props.iconSize;
computed: { }
classes(): string[] { if (props.theme === 'secondary') {
return [ return 'medium';
'n8n-callout', }
this.$style.callout, return 'large';
this.$style[this.theme],
this.slim ? this.$style.slim : '',
this.roundCorners ? this.$style.round : '',
];
},
getIcon(): string {
return this.icon ?? CALLOUT_DEFAULT_ICONS?.[this.theme] ?? CALLOUT_DEFAULT_ICONS.info;
},
getIconSize(): string {
if (this.iconSize) {
return this.iconSize;
}
if (this.theme === 'secondary') {
return 'medium';
}
return 'large';
},
},
}); });
</script> </script>

View file

@ -1,3 +1,3 @@
import N8nCallout from './Callout.vue'; import N8nCallout from './Callout.vue';
export type { CalloutTheme } from './Callout.vue';
export default N8nCallout; export default N8nCallout;

View file

@ -20,28 +20,24 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed, useCssModule } from 'vue';
export default defineComponent({ interface CardProps {
name: 'N8nCard', hoverable?: boolean;
inheritAttrs: true, }
props: {
hoverable: { defineOptions({ name: 'N8nCard' });
type: Boolean, const props = withDefaults(defineProps<CardProps>(), {
default: false, hoverable: false,
},
},
computed: {
classes(): Record<string, boolean> {
return {
card: true,
[this.$style.card]: true,
[this.$style.hoverable]: this.hoverable,
};
},
},
}); });
const $style = useCssModule();
const classes = computed(() => ({
card: true,
[$style.card]: true,
[$style.hoverable]: props.hoverable,
}));
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -20,56 +20,38 @@
</ElCheckbox> </ElCheckbox>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { ref } from 'vue';
import { ElCheckbox } from 'element-plus'; import { ElCheckbox } from 'element-plus';
import N8nInputLabel from '../N8nInputLabel'; import N8nInputLabel from '../N8nInputLabel';
export default defineComponent({ const LABEL_SIZE = ['small', 'medium'] as const;
name: 'N8nCheckbox',
components: {
ElCheckbox,
N8nInputLabel,
},
props: {
label: {
type: String,
},
disabled: {
type: Boolean,
default: false,
},
tooltipText: {
type: String,
},
indeterminate: {
type: Boolean,
default: false,
},
modelValue: {
type: Boolean,
default: false,
},
labelSize: {
type: String,
default: 'medium',
validator: (value: string): boolean => ['small', 'medium'].includes(value),
},
},
methods: {
onUpdateModelValue(value: boolean) {
this.$emit('update:modelValue', value);
},
onLabelClick() {
const checkboxComponent = this.$refs.checkbox as ElCheckbox;
if (!checkboxComponent) {
return;
}
(checkboxComponent.$el as HTMLElement).click(); interface CheckboxProps {
}, label?: string;
}, disabled?: boolean;
tooltipText?: string;
indeterminate?: boolean;
modelValue?: boolean;
labelSize?: (typeof LABEL_SIZE)[number];
}
defineOptions({ name: 'N8nCheckbox' });
withDefaults(defineProps<CheckboxProps>(), {
disabled: false,
indeterminate: false,
modelValue: false,
labelSize: 'medium',
}); });
const $emit = defineEmits(['update:modelValue']);
const onUpdateModelValue = (value: boolean) => $emit('update:modelValue', value);
const checkbox = ref<InstanceType<typeof ElCheckbox>>();
const onLabelClick = () => {
if (!checkbox?.value) return;
(checkbox.value.$el as HTMLElement).click();
};
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -4,7 +4,7 @@ import { uid } from '../../utils';
import { ElColorPicker } from 'element-plus'; import { ElColorPicker } from 'element-plus';
import N8nInput from '../N8nInput'; import N8nInput from '../N8nInput';
export type Props = { export type ColorPickerProps = {
disabled?: boolean; disabled?: boolean;
size?: 'small' | 'medium' | 'mini'; size?: 'small' | 'medium' | 'mini';
showAlpha?: boolean; showAlpha?: boolean;
@ -16,21 +16,21 @@ export type Props = {
name?: string; name?: string;
}; };
const props = withDefaults(defineProps<Props>(), { defineOptions({ name: 'N8nColorPicker' });
const props = withDefaults(defineProps<ColorPickerProps>(), {
disabled: false, disabled: false,
size: 'medium', size: 'medium',
showAlpha: false, showAlpha: false,
colorFormat: 'hex', colorFormat: 'hex',
popperClass: '', popperClass: '',
showInput: true, showInput: true,
modelValue: null,
name: uid('color-picker'), name: uid('color-picker'),
}); });
const color = ref(props.modelValue); const color = ref(props.modelValue);
const colorPickerProps = computed(() => { const colorPickerProps = computed(() => {
const { value, showInput, ...rest } = props; const { showInput, ...rest } = props;
return rest; return rest;
}); });
@ -62,6 +62,7 @@ const onActiveChange = (value: string) => {
emit('active-change', value); emit('active-change', value);
}; };
</script> </script>
<template> <template>
<span :class="['n8n-color-picker', $style.component]"> <span :class="['n8n-color-picker', $style.component]">
<ElColorPicker <ElColorPicker
@ -82,6 +83,7 @@ const onActiveChange = (value: string) => {
/> />
</span> </span>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.component { .component {
display: inline-flex; display: inline-flex;

View file

@ -1,118 +1,3 @@
<script lang="ts">
import type { PropType } from 'vue';
import { computed, defineComponent, ref, useCssModule } from 'vue';
import type { DatatableColumn, DatatableRow, DatatableRowDataType } from '../../types';
import { getValueByPath } from '../../utils';
import { useI18n } from '../../composables/useI18n';
import N8nSelect from '../N8nSelect';
import N8nOption from '../N8nOption';
import N8nPagination from '../N8nPagination';
export default defineComponent({
name: 'N8nDatatable',
components: {
N8nSelect,
N8nOption,
N8nPagination,
},
props: {
columns: {
type: Array as PropType<DatatableColumn[]>,
required: true,
},
rows: {
type: Array as PropType<DatatableRow[]>,
required: true,
},
currentPage: {
type: Number,
default: 1,
},
pagination: {
type: Boolean,
default: true,
},
rowsPerPage: {
type: [Number, String] as PropType<number | '*'>,
default: 10,
},
},
emits: ['update:currentPage', 'update:rowsPerPage'],
setup(props, { emit }) {
const { t } = useI18n();
const rowsPerPageOptions = ref([10, 25, 50, 100]);
const style = useCssModule();
const totalPages = computed(() => {
if (props.rowsPerPage === '*') {
return 1;
}
return Math.ceil(props.rows.length / props.rowsPerPage);
});
const totalRows = computed(() => {
return props.rows.length;
});
const visibleRows = computed(() => {
if (props.rowsPerPage === '*') {
return props.rows;
}
const start = (props.currentPage - 1) * props.rowsPerPage;
const end = start + props.rowsPerPage;
return props.rows.slice(start, end);
});
const classes = computed(() => {
return {
datatable: true,
[style.datatableWrapper]: true,
};
});
function onUpdateCurrentPage(value: number) {
emit('update:currentPage', value);
}
function onRowsPerPageChange(value: number | '*') {
emit('update:rowsPerPage', value);
const maxPage = value === '*' ? 1 : Math.ceil(totalRows.value / value);
if (maxPage < props.currentPage) {
onUpdateCurrentPage(maxPage);
}
}
function getTdValue(row: DatatableRow, column: DatatableColumn) {
return getValueByPath<DatatableRowDataType>(row, column.path);
}
function getThStyle(column: DatatableColumn) {
return {
...(column.width ? { width: column.width } : {}),
};
}
return {
t,
classes,
totalPages,
totalRows,
visibleRows,
rowsPerPageOptions,
getTdValue,
getThStyle,
onUpdateCurrentPage,
onRowsPerPageChange,
};
},
});
</script>
<template> <template>
<div :class="classes" v-bind="$attrs"> <div :class="classes" v-bind="$attrs">
<table :class="$style.datatable"> <table :class="$style.datatable">
@ -175,6 +60,89 @@ export default defineComponent({
</div> </div>
</template> </template>
<script lang="ts" setup>
import { computed, ref, useCssModule } from 'vue';
import N8nSelect from '../N8nSelect';
import N8nOption from '../N8nOption';
import N8nPagination from '../N8nPagination';
import type { DatatableColumn, DatatableRow, DatatableRowDataType } from '../../types';
import { useI18n } from '../../composables/useI18n';
import { getValueByPath } from '../../utils';
interface DatatableProps {
columns: DatatableColumn[];
rows: DatatableRow[];
currentPage?: number;
pagination?: boolean;
rowsPerPage?: number | '*';
}
defineOptions({ name: 'N8nDatatable' });
const props = withDefaults(defineProps<DatatableProps>(), {
currentPage: 1,
pagination: true,
rowsPerPage: 10,
});
const $emit = defineEmits(['update:currentPage', 'update:rowsPerPage']);
const { t } = useI18n();
const rowsPerPageOptions = ref([10, 25, 50, 100]);
const $style = useCssModule();
const totalPages = computed(() => {
if (props.rowsPerPage === '*') {
return 1;
}
return Math.ceil(props.rows.length / props.rowsPerPage);
});
const totalRows = computed(() => {
return props.rows.length;
});
const visibleRows = computed(() => {
if (props.rowsPerPage === '*') {
return props.rows;
}
const start = (props.currentPage - 1) * props.rowsPerPage;
const end = start + props.rowsPerPage;
return props.rows.slice(start, end);
});
const classes = computed(() => ({
datatable: true,
[$style.datatableWrapper]: true,
}));
function onUpdateCurrentPage(value: number) {
$emit('update:currentPage', value);
}
function onRowsPerPageChange(value: number | '*') {
$emit('update:rowsPerPage', value);
const maxPage = value === '*' ? 1 : Math.ceil(totalRows.value / value);
if (maxPage < props.currentPage) {
onUpdateCurrentPage(maxPage);
}
}
function getTdValue(row: DatatableRow, column: DatatableColumn) {
return getValueByPath<DatatableRowDataType>(row, column.path);
}
function getThStyle(column: DatatableColumn) {
return {
...(column.width ? { width: column.width } : {}),
};
}
</script>
<style lang="scss" module> <style lang="scss" module>
.datatableWrapper { .datatableWrapper {
display: block; display: block;

View file

@ -16,81 +16,81 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
<td class=""><span>1</span></td> <td class=""><span>1</span></td>
<td class=""><span>Richard Hendricks</span></td> <td class=""><span>Richard Hendricks</span></td>
<td class=""><span>29</span></td> <td class=""><span>29</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 1</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>2</span></td> <td class=""><span>2</span></td>
<td class=""><span>Bertram Gilfoyle</span></td> <td class=""><span>Bertram Gilfoyle</span></td>
<td class=""><span>44</span></td> <td class=""><span>44</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 2</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>3</span></td> <td class=""><span>3</span></td>
<td class=""><span>Dinesh Chugtai</span></td> <td class=""><span>Dinesh Chugtai</span></td>
<td class=""><span>31</span></td> <td class=""><span>31</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 3</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>4</span></td> <td class=""><span>4</span></td>
<td class=""><span>Jared Dunn </span></td> <td class=""><span>Jared Dunn </span></td>
<td class=""><span>38</span></td> <td class=""><span>38</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 4</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>5</span></td> <td class=""><span>5</span></td>
<td class=""><span>Richard Hendricks</span></td> <td class=""><span>Richard Hendricks</span></td>
<td class=""><span>29</span></td> <td class=""><span>29</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 5</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>6</span></td> <td class=""><span>6</span></td>
<td class=""><span>Bertram Gilfoyle</span></td> <td class=""><span>Bertram Gilfoyle</span></td>
<td class=""><span>44</span></td> <td class=""><span>44</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 6</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>7</span></td> <td class=""><span>7</span></td>
<td class=""><span>Dinesh Chugtai</span></td> <td class=""><span>Dinesh Chugtai</span></td>
<td class=""><span>31</span></td> <td class=""><span>31</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 7</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>8</span></td> <td class=""><span>8</span></td>
<td class=""><span>Jared Dunn </span></td> <td class=""><span>Jared Dunn </span></td>
<td class=""><span>38</span></td> <td class=""><span>38</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 8</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>9</span></td> <td class=""><span>9</span></td>
<td class=""><span>Richard Hendricks</span></td> <td class=""><span>Richard Hendricks</span></td>
<td class=""><span>29</span></td> <td class=""><span>29</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 9</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
<tr> <tr>
<td class=""><span>10</span></td> <td class=""><span>10</span></td>
<td class=""><span>Bertram Gilfoyle</span></td> <td class=""><span>Bertram Gilfoyle</span></td>
<td class=""><span>44</span></td> <td class=""><span>44</span></td>
<td class=""><button class="button button primary medium" aria-live="polite" column="[object Object]"> <td class="">
<!--v-if--><span>Button 10</span> <n8n-button-stub block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="primary" column="[object Object]"></n8n-button-stub>
</button></td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -38,70 +38,40 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue';
import N8nFormInputs from '../N8nFormInputs'; import N8nFormInputs from '../N8nFormInputs';
import N8nHeading from '../N8nHeading'; import N8nHeading from '../N8nHeading';
import N8nLink from '../N8nLink'; import N8nLink from '../N8nLink';
import N8nButton from '../N8nButton'; import N8nButton from '../N8nButton';
import type { IFormInput } from '@/types';
import { createEventBus } from '../../utils'; import { createEventBus } from '../../utils';
export default defineComponent({ interface FormBoxProps {
name: 'N8nFormBox', title?: string;
components: { inputs?: IFormInput[];
N8nHeading, buttonText?: string;
N8nFormInputs, buttonLoading?: boolean;
N8nLink, secondaryButtonText?: string;
N8nButton, redirectText?: string;
}, redirectLink?: string;
props: { }
title: {
type: String, defineOptions({ name: 'N8nFormBox' });
default: '', withDefaults(defineProps<FormBoxProps>(), {
}, title: '',
inputs: { inputs: () => [],
type: Array, buttonLoading: false,
default: () => [], redirectText: '',
}, redirectLink: '',
buttonText: {
type: String,
},
buttonLoading: {
type: Boolean,
default: false,
},
secondaryButtonText: {
type: String,
},
redirectText: {
type: String,
default: '',
},
redirectLink: {
type: String,
default: '',
},
},
data() {
return {
formBus: createEventBus(),
};
},
methods: {
onUpdateModelValue(e: { name: string; value: string }) {
this.$emit('update', e);
},
onSubmit(e: { [key: string]: string }) {
this.$emit('submit', e);
},
onButtonClick() {
this.formBus.emit('submit');
},
onSecondaryButtonClick(event: Event) {
this.$emit('secondaryClick', event);
},
},
}); });
const formBus = createEventBus();
const $emit = defineEmits(['submit', 'update', 'secondaryClick']);
const onUpdateModelValue = (e: { name: string; value: string }) => $emit('update', e);
const onSubmit = (e: { [key: string]: string }) => $emit('submit', e);
const onButtonClick = () => formBus.emit('submit');
const onSecondaryButtonClick = (event: Event) => $emit('secondaryClick', event);
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -143,7 +143,7 @@ const props = withDefaults(defineProps<Props>(), {
tagSize: 'small', tagSize: 'small',
}); });
const emit = defineEmits<{ const $emit = defineEmits<{
(event: 'validate', shouldValidate: boolean): void; (event: 'validate', shouldValidate: boolean): void;
(event: 'update:modelValue', value: unknown): void; (event: 'update:modelValue', value: unknown): void;
(event: 'focus'): void; (event: 'focus'): void;
@ -203,22 +203,22 @@ function getInputValidationError(): ReturnType<IValidator['validate']> {
function onBlur() { function onBlur() {
state.hasBlurred = true; state.hasBlurred = true;
state.isTyping = false; state.isTyping = false;
emit('blur'); $emit('blur');
} }
function onUpdateModelValue(value: FormState) { function onUpdateModelValue(value: FormState) {
state.isTyping = true; state.isTyping = true;
emit('update:modelValue', value); $emit('update:modelValue', value);
} }
function onFocus() { function onFocus() {
emit('focus'); $emit('focus');
} }
function onEnter(event: Event) { function onEnter(event: Event) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
emit('enter'); $emit('enter');
} }
const validationError = computed<string | null>(() => { const validationError = computed<string | null>(() => {
@ -244,14 +244,14 @@ const showErrors = computed(
); );
onMounted(() => { onMounted(() => {
emit('validate', !validationError.value); $emit('validate', !validationError.value);
if (props.focusInitially && inputRef.value) inputRef.value.focus(); if (props.focusInitially && inputRef.value) inputRef.value.focus();
}); });
watch( watch(
() => validationError.value, () => validationError.value,
(error) => emit('validate', !error), (error) => $emit('validate', !error),
); );
defineExpose({ inputRef }); defineExpose({ inputRef });

View file

@ -4,55 +4,49 @@
</component> </component>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed, useCssModule } from 'vue';
export default defineComponent({ const SIZES = ['2xlarge', 'xlarge', 'large', 'medium', 'small'] as const;
name: 'N8nHeading', const COLORS = [
props: { 'primary',
tag: { 'text-dark',
type: String, 'text-base',
default: 'span', 'text-light',
}, 'text-xlight',
bold: { 'danger',
type: Boolean, ] as const;
default: false, const ALIGN = ['right', 'left', 'center'] as const;
},
size: {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['2xlarge', 'xlarge', 'large', 'medium', 'small'].includes(value),
},
color: {
type: String,
validator: (value: string): boolean =>
['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight', 'danger'].includes(
value,
),
},
align: {
type: String,
validator: (value: string): boolean => ['right', 'left', 'center'].includes(value),
},
},
computed: {
classes() {
const applied = [];
if (this.align) {
applied.push(`align-${this.align}`);
}
if (this.color) {
applied.push(this.color);
}
applied.push(`size-${this.size}`); interface HeadingProps {
tag?: string;
bold?: boolean;
size?: (typeof SIZES)[number];
color?: (typeof COLORS)[number];
align?: (typeof ALIGN)[number];
}
applied.push(this.bold ? 'bold' : 'regular'); defineOptions({ name: 'N8nHeading' });
const props = withDefaults(defineProps<HeadingProps>(), {
tag: 'span',
bold: false,
size: 'medium',
});
return applied.map((c) => this.$style[c]); const $style = useCssModule();
}, const classes = computed(() => {
}, const applied: string[] = [];
if (props.align) {
applied.push(`align-${props.align}`);
}
if (props.color) {
applied.push(props.color);
}
applied.push(`size-${props.size}`);
applied.push(props.bold ? 'bold' : 'regular');
return applied.map((c) => $style[c]);
}); });
</script> </script>

View file

@ -4,34 +4,22 @@
</N8nText> </N8nText>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import type { IconSize, IconColor } from '@/types/icon';
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import { defineComponent } from 'vue'; interface IconProps {
icon: string;
size?: IconSize;
spin?: boolean;
color?: IconColor;
}
export default defineComponent({ defineOptions({ name: 'N8nIcon' });
name: 'N8nIcon', withDefaults(defineProps<IconProps>(), {
components: { size: 'medium',
FontAwesomeIcon, spin: false,
N8nText,
},
props: {
icon: {
required: true,
},
size: {
type: String,
default: 'medium',
},
spin: {
type: Boolean,
default: false,
},
color: {
type: String,
},
},
}); });
</script> </script>

View file

@ -2,53 +2,18 @@
<N8nButton square v-bind="{ ...$attrs, ...$props }" /> <N8nButton square v-bind="{ ...$attrs, ...$props }" />
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import type { IconButtonProps } from '@/types/button';
import N8nButton from '../N8nButton'; import N8nButton from '../N8nButton';
import { defineComponent } from 'vue'; defineOptions({ name: 'N8nIconButton' });
withDefaults(defineProps<IconButtonProps>(), {
export default defineComponent({ type: 'primary',
name: 'N8nIconButton', size: 'medium',
components: { loading: false,
N8nButton, outline: false,
}, text: false,
props: { disabled: false,
type: { active: false,
type: String,
default: 'primary',
},
size: {
type: String,
default: 'medium',
},
loading: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
text: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
active: {
type: Boolean,
default: false,
},
icon: {
type: [String, Array],
required: true,
},
float: {
type: String,
validator: (value: string): boolean => ['left', 'right'].includes(value),
},
},
}); });
</script> </script>

View file

@ -38,75 +38,53 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { onMounted } from 'vue';
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
import type { PropType } from 'vue'; import type { IconColor } from '@/types/icon';
import { defineComponent } from 'vue'; import { createEventBus, type EventBus } from '../../utils';
import type { EventBus } from '../../utils';
import { createEventBus } from '../../utils';
export interface IAccordionItem { interface IAccordionItem {
id: string; id: string;
label: string; label: string;
icon: string; icon: string;
iconColor?: string; iconColor?: IconColor;
tooltip?: string; tooltip?: string;
} }
export default defineComponent({ interface InfoAccordionProps {
name: 'N8nInfoAccordion', title?: string;
components: { description?: string;
N8nText, items?: IAccordionItem[];
N8nIcon, initiallyExpanded?: boolean;
}, headerIcon?: { icon: string; color: IconColor };
props: { eventBus?: EventBus;
title: { }
type: String,
}, defineOptions({ name: 'N8nInfoAccordion' });
description: { const props = withDefaults(defineProps<InfoAccordionProps>(), {
type: String, items: () => [],
}, initiallyExpanded: false,
items: { eventBus: () => createEventBus(),
type: Array as PropType<IAccordionItem[]>,
default: () => [],
},
initiallyExpanded: {
type: Boolean,
default: false,
},
headerIcon: {
type: Object as PropType<{ icon: string; color: string }>,
required: false,
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
},
data() {
return {
expanded: false,
};
},
mounted() {
this.eventBus.on('expand', () => {
this.expanded = true;
});
this.expanded = this.initiallyExpanded;
},
methods: {
toggle() {
this.expanded = !this.expanded;
},
onClick(e: MouseEvent) {
this.$emit('click:body', e);
},
onTooltipClick(item: string, event: MouseEvent) {
this.$emit('tooltipClick', item, event);
},
},
}); });
const $emit = defineEmits(['click:body', 'tooltipClick']);
let expanded = false;
onMounted(() => {
props.eventBus.on('expand', () => {
expanded = true;
});
expanded = props.initiallyExpanded;
});
const toggle = () => {
expanded = !expanded;
};
const onClick = (e: MouseEvent) => $emit('click:body', e);
const onTooltipClick = (item: string, event: MouseEvent) => $emit('tooltipClick', item, event);
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -32,75 +32,63 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed } from 'vue';
import type { Placement } from 'element-plus';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
import N8nTooltip from '../N8nTooltip'; import N8nTooltip from '../N8nTooltip';
import { defineComponent } from 'vue'; const THEME = ['info', 'info-light', 'warning', 'danger', 'success'] as const;
const TYPE = ['note', 'tooltip'] as const;
export default defineComponent({ interface InfoTipProps {
name: 'N8nInfoTip', theme?: (typeof THEME)[number];
components: { type?: (typeof TYPE)[number];
N8nIcon, bold?: boolean;
N8nTooltip, tooltipPlacement?: Placement;
}, }
props: {
theme: { defineOptions({ name: 'N8nInfoTip' });
type: String, const props = withDefaults(defineProps<InfoTipProps>(), {
default: 'info', theme: 'info',
validator: (value: string): boolean => type: 'note',
['info', 'info-light', 'warning', 'danger', 'success'].includes(value), bold: true,
}, tooltipPlacement: 'top',
type: { });
type: String,
default: 'note', const iconData = computed((): { icon: string; color: string } => {
validator: (value: string): boolean => ['note', 'tooltip'].includes(value), switch (props.theme) {
}, case 'info':
bold: { return {
type: Boolean, icon: 'info-circle',
default: true, color: '--color-text-light)',
}, };
tooltipPlacement: { case 'info-light':
type: String, return {
default: 'top', icon: 'info-circle',
}, color: 'var(--color-foreground-dark)',
}, };
computed: { case 'warning':
iconData(): { icon: string; color: string } { return {
switch (this.theme) { icon: 'exclamation-triangle',
case 'info': color: 'var(--color-warning)',
return { };
icon: 'info-circle', case 'danger':
color: '--color-text-light)', return {
}; icon: 'exclamation-triangle',
case 'info-light': color: 'var(--color-danger)',
return { };
icon: 'info-circle', case 'success':
color: 'var(--color-foreground-dark)', return {
}; icon: 'check-circle',
case 'warning': color: 'var(--color-success)',
return { };
icon: 'exclamation-triangle', default:
color: 'var(--color-warning)', return {
}; icon: 'info-circle',
case 'danger': color: '--color-text-light)',
return { };
icon: 'exclamation-triangle', }
color: 'var(--color-danger)',
};
case 'success':
return {
icon: 'check-circle',
color: 'var(--color-success)',
};
default:
return {
icon: 'info-circle',
color: '--color-text-light)',
};
}
},
},
}); });
</script> </script>

View file

@ -22,133 +22,68 @@
</ElInput> </ElInput>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed, ref } from 'vue';
import { ElInput } from 'element-plus'; import { ElInput } from 'element-plus';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { uid } from '../../utils'; import { uid } from '../../utils';
type InputRef = InstanceType<typeof ElInput>; const INPUT = ['text', 'textarea', 'number', 'password', 'email'] as const;
const SIZE = ['mini', 'small', 'medium', 'large', 'xlarge'] as const;
export default defineComponent({ interface InputProps {
name: 'N8nInput', modelValue?: string | number;
components: { type?: (typeof INPUT)[number];
ElInput, size?: (typeof SIZE)[number];
}, placeholder?: string;
props: { disabled?: boolean;
modelValue: { readonly?: boolean;
type: [String, Number] as PropType<string | number>, clearable?: boolean;
default: '', rows?: number;
}, maxlength?: number;
type: { title?: string;
type: String, name?: string;
validator: (value: string): boolean => autocomplete?: 'off' | 'autocomplete';
['text', 'textarea', 'number', 'password', 'email'].includes(value), }
},
size: {
type: String,
default: 'large',
validator: (value: string): boolean =>
['mini', 'small', 'medium', 'large', 'xlarge'].includes(value),
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
clearable: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 2,
},
maxlength: {
type: Number,
default: Infinity,
},
title: {
type: String,
default: '',
},
name: {
type: String,
default: () => uid('input'),
},
autocomplete: {
type: String,
default: 'off',
},
},
computed: {
computedSize(): string | undefined {
if (this.size === 'xlarge') {
return undefined;
}
return this.size; defineOptions({ name: 'N8nInput' });
}, const props = withDefaults(defineProps<InputProps>(), {
classes(): string[] { modelValue: '',
const classes = []; size: 'large',
if (this.size === 'xlarge') { placeholder: '',
classes.push('xlarge'); disabled: false,
} readonly: false,
if (this.type === 'password') { clearable: false,
classes.push('ph-no-capture'); rows: 2,
} maxlength: Infinity,
return classes; title: '',
}, name: () => uid('input'),
}, autocomplete: 'off',
methods: {
focus() {
const innerInput = this.$refs.innerInput as InputRef | undefined;
if (!innerInput) return;
const inputElement = innerInput.$el.querySelector(
this.type === 'textarea' ? 'textarea' : 'input',
);
if (!inputElement) return;
inputElement.focus();
},
blur() {
const innerInput = this.$refs.innerInput as InputRef | undefined;
if (!innerInput) return;
const inputElement = innerInput.$el.querySelector(
this.type === 'textarea' ? 'textarea' : 'input',
);
if (!inputElement) return;
inputElement.blur();
},
select() {
const innerInput = this.$refs.innerInput as InputRef | undefined;
if (!innerInput) return;
const inputElement = innerInput.$el.querySelector(
this.type === 'textarea' ? 'textarea' : 'input',
);
if (!inputElement) return;
inputElement.select();
},
},
}); });
const computedSize = computed(() => (props.size === 'xlarge' ? undefined : props.size));
const classes = computed(() => {
const applied: string[] = [];
if (props.size === 'xlarge') {
applied.push('xlarge');
}
if (props.type === 'password') {
applied.push('ph-no-capture');
}
return applied;
});
const innerInput = ref<InstanceType<typeof ElInput>>();
const inputElement = computed(() => {
if (!innerInput?.value) return;
const inputType = props.type === 'textarea' ? 'textarea' : 'input';
return (innerInput.value.$el as HTMLElement).querySelector(inputType);
});
const focus = () => inputElement.value?.focus();
const blur = () => inputElement.value?.blur();
const select = () => inputElement.value?.select();
defineExpose({ focus, blur, select });
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -45,65 +45,37 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import N8nTooltip from '../N8nTooltip';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
import N8nTooltip from '../N8nTooltip';
import type { TextColor } from '@/types/text';
import { addTargetBlank } from '../utils/helpers'; const SIZE = ['small', 'medium'] as const;
import { defineComponent } from 'vue'; interface InputLabelProps {
compact?: boolean;
color?: TextColor;
label?: string;
tooltipText?: string;
inputName?: string;
required?: boolean;
bold?: boolean;
size?: (typeof SIZE)[number];
underline?: boolean;
showTooltip?: boolean;
showOptions?: boolean;
}
export default defineComponent({ defineOptions({ name: 'N8nInputLabel' });
name: 'N8nInputLabel', withDefaults(defineProps<InputLabelProps>(), {
components: { compact: false,
N8nText, bold: true,
N8nIcon, size: 'medium',
N8nTooltip,
},
props: {
compact: {
type: Boolean,
default: false,
},
color: {
type: String,
},
label: {
type: String,
},
tooltipText: {
type: String,
},
inputName: {
type: String,
},
required: {
type: Boolean,
},
bold: {
type: Boolean,
default: true,
},
size: {
type: String,
default: 'medium',
validator: (value: string): boolean => ['small', 'medium'].includes(value),
},
underline: {
type: Boolean,
},
showTooltip: {
type: Boolean,
},
showOptions: {
type: Boolean,
},
},
methods: {
addTargetBlank,
},
}); });
const addTargetBlank = (html: string) =>
html && html.includes('href=') ? html.replace(/href=/g, 'target="_blank" href=') : html;
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -8,43 +8,27 @@
</N8nRoute> </N8nRoute>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue';
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import N8nRoute from '../N8nRoute'; import N8nRoute, { type RouteTo } from '../N8nRoute';
import type { TextSize } from '@/types/text';
export default defineComponent({ const THEME = ['primary', 'danger', 'text', 'secondary'] as const;
name: 'N8nLink',
components: { interface LinkProps {
N8nText, size?: TextSize;
N8nRoute, to?: RouteTo;
}, newWindow?: boolean;
props: { bold?: boolean;
size: { underline?: boolean;
type: String, theme?: (typeof THEME)[number];
}, }
to: {
type: String || Object, defineOptions({ name: 'N8nLink' });
}, withDefaults(defineProps<LinkProps>(), {
newWindow: { bold: false,
type: Boolean || undefined, underline: false,
default: undefined, theme: 'primary',
},
bold: {
type: Boolean,
default: false,
},
underline: {
type: Boolean,
default: false,
},
theme: {
type: String,
default: 'primary',
validator: (value: string): boolean =>
['primary', 'danger', 'text', 'secondary'].includes(value),
},
},
}); });
</script> </script>

View file

@ -35,52 +35,37 @@
</ElSkeleton> </ElSkeleton>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { ElSkeleton, ElSkeletonItem } from 'element-plus'; import { ElSkeleton, ElSkeletonItem } from 'element-plus';
import { defineComponent } from 'vue';
export default defineComponent({ const VARIANT = [
name: 'N8nLoading', 'custom',
components: { 'p',
ElSkeleton, 'text',
ElSkeletonItem, 'h1',
}, 'h3',
props: { 'text',
animated: { 'caption',
type: Boolean, 'button',
default: true, 'image',
}, 'circle',
loading: { 'rect',
type: Boolean, ] as const;
default: true,
}, interface LoadingProps {
rows: { animated?: boolean;
type: Number, loading?: boolean;
default: 1, rows?: number;
}, shrinkLast?: boolean;
shrinkLast: { variant?: (typeof VARIANT)[number];
type: Boolean, }
default: true,
}, withDefaults(defineProps<LoadingProps>(), {
variant: { animated: true,
type: String, loading: true,
default: 'p', rows: 1,
validator: (value: string): boolean => shrinkLast: true,
[ variant: 'p',
'custom',
'p',
'text',
'h1',
'h3',
'text',
'caption',
'button',
'image',
'circle',
'rect',
].includes(value),
},
},
}); });
</script> </script>

View file

@ -16,172 +16,141 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import N8nLoading from '../N8nLoading'; import { computed } from 'vue';
import type { PluginSimple } from 'markdown-it'; import type { Options as MarkdownOptions } from 'markdown-it';
import Markdown from 'markdown-it'; import Markdown from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes'; import markdownLink from 'markdown-it-link-attributes';
import markdownEmoji from 'markdown-it-emoji'; import markdownEmoji from 'markdown-it-emoji';
import markdownTasklists from 'markdown-it-task-lists'; import markdownTaskLists from 'markdown-it-task-lists';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import xss, { friendlyAttrValue } from 'xss'; import xss, { friendlyAttrValue } from 'xss';
import N8nLoading from '../N8nLoading';
import { escapeMarkdown } from '../../utils/markdown'; import { escapeMarkdown } from '../../utils/markdown';
const DEFAULT_OPTIONS_MARKDOWN = { interface IImage {
html: true,
linkify: true,
typographer: true,
breaks: true,
} as const;
const DEFAULT_OPTIONS_LINK_ATTRIBUTES = {
attrs: {
target: '_blank',
rel: 'noopener',
},
} as const;
const DEFAULT_OPTIONS_TASKLISTS = {
label: true,
labelAfter: true,
} as const;
export interface IImage {
id: string; id: string;
url: string; url: string;
} }
export interface Options { interface Options {
markdown: typeof DEFAULT_OPTIONS_MARKDOWN; markdown: MarkdownOptions;
linkAttributes: typeof DEFAULT_OPTIONS_LINK_ATTRIBUTES; linkAttributes: markdownLink.Config;
tasklists: typeof DEFAULT_OPTIONS_TASKLISTS; tasklists: markdownTaskLists.Config;
} }
export default defineComponent({ interface MarkdownProps {
name: 'N8nMarkdown', content?: string;
components: { withMultiBreaks?: boolean;
N8nLoading, images?: IImage[];
}, loading?: boolean;
props: { loadingBlocks?: number;
content: { loadingRows?: number;
type: String, theme?: string;
default: '', options?: Options;
}
const props = withDefaults(defineProps<MarkdownProps>(), {
content: '',
withMultiBreaks: false,
images: () => [],
loading: false,
loadingBlocks: 2,
loadingRows: 3,
theme: 'markdown',
options: () => ({
markdown: {
html: true,
linkify: true,
typographer: true,
breaks: true,
}, },
withMultiBreaks: { linkAttributes: {
type: Boolean, attrs: {
default: false, target: '_blank',
rel: 'noopener',
},
}, },
images: { tasklists: {
type: Array as PropType<IImage[]>, label: true,
default: () => [], labelAfter: true,
}, },
loading: { }),
type: Boolean, });
default: false,
}, const { options } = props;
loadingBlocks: { const md = new Markdown(options.markdown)
type: Number, .use(markdownLink, options.linkAttributes)
default: 2, .use(markdownEmoji)
}, .use(markdownTaskLists, options.tasklists);
loadingRows: {
type: Number, const htmlContent = computed(() => {
default: 3, if (!props.content) {
}, return '';
theme: { }
type: String,
default: 'markdown', const imageUrls: { [key: string]: string } = {};
}, if (props.images) {
options: { props.images.forEach((image: IImage) => {
type: Object as PropType<Options>, if (!image) {
default: (): Options => ({ // Happens if an image got deleted but the workflow
markdown: DEFAULT_OPTIONS_MARKDOWN, // still has a reference to it
linkAttributes: DEFAULT_OPTIONS_LINK_ATTRIBUTES, return;
tasklists: DEFAULT_OPTIONS_TASKLISTS,
}),
},
},
data(): { md: Markdown } {
return {
md: new Markdown(this.options.markdown)
.use(markdownLink, this.options.linkAttributes)
.use(markdownEmoji)
.use(markdownTasklists as PluginSimple, this.options.tasklists),
};
},
computed: {
htmlContent(): string {
if (!this.content) {
return '';
} }
imageUrls[image.id] = image.url;
});
}
const imageUrls: { [key: string]: string } = {}; const fileIdRegex = new RegExp('fileId:([0-9]+)');
if (this.images) { let contentToRender = props.content;
this.images.forEach((image: IImage) => { if (props.withMultiBreaks) {
if (!image) { contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n');
// Happens if an image got deleted but the workflow }
// still has a reference to it const html = md.render(escapeMarkdown(contentToRender));
return; const safeHtml = xss(html, {
} onTagAttr: (tag, name, value) => {
imageUrls[image.id] = image.url; if (tag === 'img' && name === 'src') {
}); if (value.match(fileIdRegex)) {
} const id = value.split('fileId:')[1];
const attributeValue = friendlyAttrValue(imageUrls[id]);
const fileIdRegex = new RegExp('fileId:([0-9]+)'); return attributeValue ? `src=${attributeValue}` : '';
let contentToRender = this.content; }
if (this.withMultiBreaks) { // Only allow http requests to supported image files from the `static` directory
contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n'); const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null;
} const isStaticImageFile = isImageFile && value.startsWith('/static/');
const html = this.md.render(escapeMarkdown(contentToRender)); if (!value.startsWith('https://') && !isStaticImageFile) {
const safeHtml = xss(html, { return '';
onTagAttr: (tag, name, value) => {
if (tag === 'img' && name === 'src') {
if (value.match(fileIdRegex)) {
const id = value.split('fileId:')[1];
const attributeValue = friendlyAttrValue(imageUrls[id]);
return attributeValue ? `src=${attributeValue}` : '';
}
// Only allow http requests to supported image files from the `static` directory
const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null;
const isStaticImageFile = isImageFile && value.startsWith('/static/');
if (!value.startsWith('https://') && !isStaticImageFile) {
return '';
}
}
// Return nothing, means keep the default handling measure
},
onTag(tag, code) {
if (tag === 'img' && code.includes('alt="workflow-screenshot"')) {
return '';
}
// return nothing, keep tag
},
});
return safeHtml;
},
},
methods: {
onClick(event: MouseEvent) {
let clickedLink = null;
if (event.target instanceof HTMLAnchorElement) {
clickedLink = event.target;
}
if (event.target instanceof HTMLElement && event.target.matches('a *')) {
const parentLink = event.target.closest('a');
if (parentLink) {
clickedLink = parentLink;
} }
} }
this.$emit('markdown-click', clickedLink, event); // Return nothing, means keep the default handling measure
}, },
}, onTag(tag, code) {
if (tag === 'img' && code.includes('alt="workflow-screenshot"')) {
return '';
}
// return nothing, keep tag
},
});
return safeHtml;
}); });
const $emit = defineEmits(['markdown-click']);
const onClick = (event: MouseEvent) => {
let clickedLink: HTMLAnchorElement | null = null;
if (event.target instanceof HTMLAnchorElement) {
clickedLink = event.target;
}
if (event.target instanceof HTMLElement && event.target.matches('a *')) {
const parentLink = event.target.closest('a');
if (parentLink) {
clickedLink = parentLink;
}
}
$emit('markdown-click', clickedLink, event);
};
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -120,7 +120,7 @@ export default defineComponent({
}, },
currentRoute(): RouteObject { currentRoute(): RouteObject {
return ( return (
(this as typeof this & { $route: RouteObject }).$route || { this.$route || {
name: '', name: '',
path: '', path: '',
} }

View file

@ -138,7 +138,7 @@ export default defineComponent({
}, },
currentRoute(): RouteObject { currentRoute(): RouteObject {
return ( return (
(this as typeof this & { $route: RouteObject }).$route || { this.$route || {
name: '', name: '',
path: '', path: '',
} }

View file

@ -38,6 +38,7 @@
<script lang="ts"> <script lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { defineComponent, type PropType } from 'vue'; import { defineComponent, type PropType } from 'vue';
import type { Placement } from 'element-plus';
import N8nTooltip from '../N8nTooltip'; import N8nTooltip from '../N8nTooltip';
export default defineComponent({ export default defineComponent({
@ -77,7 +78,7 @@ export default defineComponent({
type: Boolean, type: Boolean,
}, },
tooltipPosition: { tooltipPosition: {
type: String, type: String as PropType<Placement>,
default: 'top', default: 'top',
}, },
badge: { type: Object as PropType<{ src: string; type: string }> }, badge: { type: Object as PropType<{ src: string; type: string }> },

View file

@ -3,6 +3,7 @@ import { defineComponent } from 'vue';
import { ElPagination } from 'element-plus'; import { ElPagination } from 'element-plus';
export default defineComponent({ export default defineComponent({
name: 'N8nPagination',
components: { components: {
ElPagination, ElPagination,
}, },

View file

@ -7,43 +7,41 @@
</a> </a>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed } from 'vue';
export default defineComponent({ // TODO: replace `object` with a more detailed type
name: 'N8nRoute', export type RouteTo = string | object;
props: {
to: {
type: String || Object,
},
newWindow: {
type: Boolean || undefined,
default: undefined,
},
},
computed: {
useRouterLink() {
if (this.newWindow) {
// router-link does not support click events and opening in new window
return false;
}
if (typeof this.to === 'string') { interface RouteProps {
return this.to.startsWith('/'); to?: RouteTo;
} newWindow?: boolean;
}
return this.to !== undefined; defineOptions({ name: 'N8nRoute' });
}, const props = withDefaults(defineProps<RouteProps>(), {});
openNewWindow() {
if (this.newWindow !== undefined) {
return this.newWindow;
}
if (typeof this.to === 'string') { const useRouterLink = computed(() => {
return !this.to.startsWith('/'); if (props.newWindow) {
} // router-link does not support click events and opening in new window
return true; return false;
}, }
},
if (typeof props.to === 'string') {
return props.to.startsWith('/');
}
return props.to !== undefined;
});
const openNewWindow = computed(() => {
if (props.newWindow !== undefined) {
return props.newWindow;
}
if (typeof props.to === 'string') {
return !props.to.startsWith('/');
}
return true;
}); });
</script> </script>

View file

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

View file

@ -10,31 +10,20 @@
</span> </span>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import type { TextSize } from '@/types/text';
import N8nIcon from '../N8nIcon'; import N8nIcon from '../N8nIcon';
import { defineComponent } from 'vue'; const TYPE = ['dots', 'ring'] as const;
export default defineComponent({ interface SpinnerProps {
name: 'N8nSpinner', size?: Exclude<TextSize, 'xsmall' | 'mini' | 'xlarge'>;
components: { type?: (typeof TYPE)[number];
N8nIcon, }
},
props: { defineOptions({ name: 'N8nSpinner' });
size: { withDefaults(defineProps<SpinnerProps>(), {
type: String, type: 'dots',
validator(value: string): boolean {
return ['small', 'medium', 'large'].includes(value);
},
},
type: {
type: String,
validator(value: string): boolean {
return ['dots', 'ring'].includes(value);
},
default: 'dots',
},
},
}); });
</script> </script>

View file

@ -49,7 +49,7 @@
/> />
</div> </div>
<div v-if="editMode && shouldShowFooter" :class="$style.footer"> <div v-if="editMode && shouldShowFooter" :class="$style.footer">
<N8nText size="xsmall" aligh="right"> <N8nText size="xsmall" align="right">
<span v-html="t('sticky.markdownHint')"></span> <span v-html="t('sticky.markdownHint')"></span>
</N8nText> </N8nText>
</div> </div>

View file

@ -4,17 +4,12 @@
</span> </span>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; interface TagProps {
text: string;
export default defineComponent({ }
name: 'N8nTag', defineOptions({ name: 'N8nTag' });
props: { defineProps<TagProps>();
text: {
type: String,
},
},
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -13,68 +13,55 @@
size="small" size="small"
@click.stop.prevent="onExpand" @click.stop.prevent="onExpand"
> >
{{ t('tags.showMore', `${hiddenTagsLength}`) }} {{ t('tags.showMore', [`${hiddenTagsLength}`]) }}
</N8nLink> </N8nLink>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed } from 'vue';
import N8nTag from '../N8nTag'; import N8nTag from '../N8nTag';
import N8nLink from '../N8nLink'; import N8nLink from '../N8nLink';
import Locale from '../../mixins/locale'; import { useI18n } from '../../composables/useI18n';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
export interface ITag { export interface ITag {
id: string; id: string;
name: string; name: string;
} }
export default defineComponent({ interface TagsProp {
name: 'N8nTags', tags?: ITag[];
components: { truncate?: boolean;
N8nTag, truncateAt?: number;
N8nLink, }
},
mixins: [Locale],
props: {
tags: {
type: Array as PropType<ITag[]>,
default: () => [],
},
truncate: {
type: Boolean,
default: false,
},
truncateAt: {
type: Number,
default: 3,
},
},
data() {
return {
showAll: false,
};
},
computed: {
visibleTags(): ITag[] {
if (this.truncate && !this.showAll && this.tags.length > this.truncateAt) {
return this.tags.slice(0, this.truncateAt);
}
return this.tags; defineOptions({ name: 'N8nTags' });
}, const props = withDefaults(defineProps<TagsProp>(), {
hiddenTagsLength(): number { tags: () => [],
return this.tags.length - this.truncateAt; truncate: false,
}, truncateAt: 3,
},
methods: {
onExpand() {
this.showAll = true;
this.$emit('expand', true);
},
},
}); });
const $emit = defineEmits(['expand', 'click:tag']);
const { t } = useI18n();
let showAll = false;
const visibleTags = computed((): ITag[] => {
const { tags, truncate, truncateAt } = props;
if (truncate && !showAll && tags.length > truncateAt) {
return tags.slice(0, truncateAt);
}
return tags;
});
const hiddenTagsLength = computed((): number => props.tags.length - props.truncateAt);
const onExpand = () => {
showAll = true;
$emit('expand', true);
};
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -4,69 +4,45 @@
</component> </component>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed, useCssModule } from 'vue';
export default defineComponent({ import type { TextSize, TextColor, TextAlign } from '@/types/text';
name: 'N8nText',
props: {
bold: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['xsmall', 'small', 'mini', 'medium', 'large', 'xlarge'].includes(value),
},
color: {
type: String,
validator: (value: string): boolean =>
[
'primary',
'text-dark',
'text-base',
'text-light',
'text-xlight',
'danger',
'success',
'warning',
].includes(value),
},
align: {
type: String,
validator: (value: string): boolean => ['right', 'left', 'center'].includes(value),
},
compact: {
type: Boolean,
default: false,
},
tag: {
type: String,
default: 'span',
},
},
computed: {
classes() {
const applied = [];
if (this.align) {
applied.push(`align-${this.align}`);
}
if (this.color) {
applied.push(this.color);
}
if (this.compact) { interface TextProps {
applied.push('compact'); bold?: boolean;
} size?: TextSize;
color?: TextColor;
align?: TextAlign;
compact?: boolean;
tag?: string;
}
applied.push(`size-${this.size}`); defineOptions({ name: 'N8nText' });
const props = withDefaults(defineProps<TextProps>(), {
bold: false,
size: 'medium',
compact: false,
tag: 'span',
});
applied.push(this.bold ? 'bold' : 'regular'); const $style = useCssModule();
const classes = computed(() => {
const applied: string[] = [];
if (props.align) {
applied.push(`align-${props.align}`);
}
if (props.color) {
applied.push(props.color);
}
return applied.map((c) => this.$style[c]); if (props.compact) {
}, applied.push('compact');
}, }
applied.push(`size-${props.size}`);
applied.push(props.bold ? 'bold' : 'regular');
return applied.map((c) => $style[c]);
}); });
</script> </script>

View file

@ -25,61 +25,38 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed, useCssModule } from 'vue';
import N8nText from '../N8nText'; import N8nText from '../N8nText';
import N8nAvatar from '../N8nAvatar'; import N8nAvatar from '../N8nAvatar';
import N8nBadge from '../N8nBadge'; import N8nBadge from '../N8nBadge';
import Locale from '../../mixins/locale'; import { useI18n } from '../../composables/useI18n';
import { defineComponent } from 'vue';
export default defineComponent({ interface UsersInfoProps {
name: 'N8nUsersInfo', firstName?: string;
components: { lastName?: string;
N8nAvatar, email?: string;
N8nText, isOwner?: boolean;
N8nBadge, isPendingUser?: boolean;
}, isCurrentUser?: boolean;
mixins: [Locale], disabled?: boolean;
props: { settings?: object;
firstName: { isSamlLoginEnabled?: boolean;
type: String, }
},
lastName: { const props = withDefaults(defineProps<UsersInfoProps>(), {
type: String, disabled: false,
},
email: {
type: String,
},
isOwner: {
type: Boolean,
},
isPendingUser: {
type: Boolean,
},
isCurrentUser: {
type: Boolean,
},
disabled: {
type: Boolean,
},
settings: {
type: Object,
required: false,
},
isSamlLoginEnabled: {
type: Boolean,
required: false,
},
},
computed: {
classes(): Record<string, boolean> {
return {
[this.$style.container]: true,
[this.$style.disabled]: this.disabled,
};
},
},
}); });
const { t } = useI18n();
const $style = useCssModule();
const classes = computed(
(): Record<string, boolean> => ({
[$style.container]: true,
[$style.disabled]: props.disabled,
}),
);
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -34,101 +34,78 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import type { IUser, UserAction } from '../../types'; import { computed } from 'vue';
import N8nActionToggle from '../N8nActionToggle'; import N8nActionToggle from '../N8nActionToggle';
import N8nBadge from '../N8nBadge'; import N8nBadge from '../N8nBadge';
import N8nUserInfo from '../N8nUserInfo'; import N8nUserInfo from '../N8nUserInfo';
import Locale from '../../mixins/locale'; import type { IUser, UserAction } from '../../types';
import type { PropType } from 'vue'; import { useI18n } from '../../composables/useI18n';
import { defineComponent } from 'vue';
export default defineComponent({ interface UsersListProps {
name: 'N8nUsersList', users: IUser[];
components: { readonly?: boolean;
N8nActionToggle, currentUserId?: string;
N8nBadge, actions?: UserAction[];
N8nUserInfo, isSamlLoginEnabled?: boolean;
}, }
mixins: [Locale],
props: {
readonly: {
type: Boolean,
default: false,
},
users: {
type: Array,
required: true,
default(): IUser[] {
return [];
},
},
currentUserId: {
type: String,
},
actions: {
type: Array as PropType<UserAction[]>,
default: () => [],
},
isSamlLoginEnabled: {
type: Boolean,
default: false,
},
},
computed: {
sortedUsers(): IUser[] {
return [...(this.users as IUser[])].sort((a: IUser, b: IUser) => {
if (!a.email || !b.email) {
throw new Error('Expected all users to have email');
}
// invited users sorted by email const props = withDefaults(defineProps<UsersListProps>(), {
if (a.isPendingUser && b.isPendingUser) { readonly: false,
return a.email > b.email ? 1 : -1; users: () => [],
} actions: () => [],
isSamlLoginEnabled: false,
if (a.isPendingUser) {
return -1;
}
if (b.isPendingUser) {
return 1;
}
if (a.isOwner) {
return -1;
}
if (b.isOwner) {
return 1;
}
if (a.lastName && b.lastName && a.firstName && b.firstName) {
if (a.lastName !== b.lastName) {
return a.lastName > b.lastName ? 1 : -1;
}
if (a.firstName !== b.firstName) {
return a.firstName > b.firstName ? 1 : -1;
}
}
return a.email > b.email ? 1 : -1;
});
},
},
methods: {
getActions(user: IUser): UserAction[] {
if (user.isOwner) {
return [];
}
const defaultGuard = () => true;
return this.actions.filter((action) => (action.guard || defaultGuard)(user));
},
onUserAction(user: IUser, action: string): void {
this.$emit(action, user.id);
},
},
}); });
const { t } = useI18n();
const sortedUsers = computed(() =>
[...props.users].sort((a: IUser, b: IUser) => {
if (!a.email || !b.email) {
throw new Error('Expected all users to have email');
}
// invited users sorted by email
if (a.isPendingUser && b.isPendingUser) {
return a.email > b.email ? 1 : -1;
}
if (a.isPendingUser) {
return -1;
}
if (b.isPendingUser) {
return 1;
}
if (a.isOwner) {
return -1;
}
if (b.isOwner) {
return 1;
}
if (a.lastName && b.lastName && a.firstName && b.firstName) {
if (a.lastName !== b.lastName) {
return a.lastName > b.lastName ? 1 : -1;
}
if (a.firstName !== b.firstName) {
return a.firstName > b.firstName ? 1 : -1;
}
}
return a.email > b.email ? 1 : -1;
}),
);
const defaultGuard = () => true;
const getActions = (user: IUser): UserAction[] => {
if (user.isOwner) return [];
return props.actions.filter((action) => (action.guard || defaultGuard)(user));
};
const $emit = defineEmits(['*']);
const onUserAction = (user: IUser, action: string) => $emit(action, user.id);
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -1,3 +0,0 @@
export function addTargetBlank(html: string) {
return html && html.includes('href=') ? html.replace(/href=/g, 'target="_blank" href=') : html;
}

View file

@ -3,7 +3,7 @@ import { useDeviceSupport } from '@/composables/useDeviceSupport';
describe('useDeviceSupport()', () => { describe('useDeviceSupport()', () => {
beforeEach(() => { beforeEach(() => {
global.window = Object.create(window); global.window = Object.create(window);
global.navigator = { userAgent: 'test-agent', maxTouchPoints: 0 }; global.navigator = { userAgent: 'test-agent', maxTouchPoints: 0 } as Navigator;
}); });
describe('isTouchDevice', () => { describe('isTouchDevice', () => {

View file

@ -2,8 +2,8 @@ import { t } from '../locale';
export default { export default {
methods: { methods: {
t(...args: string[]) { t(path: string, ...args: string[]) {
return t.apply(this, args); return t.call(this, path, ...args);
}, },
}, },
}; };

View file

@ -1,4 +1,15 @@
declare module 'markdown-it-task-lists' { declare module 'markdown-it-task-lists' {
import type { PluginSimple } from 'markdown-it'; import type { PluginWithOptions } from 'markdown-it';
export default plugin as PluginSimple<{}>;
declare namespace markdownItTaskLists {
interface Config {
enabled?: boolean;
label?: boolean;
labelAfter?: boolean;
}
}
declare const markdownItTaskLists: PluginWithOptions<markdownItTaskLists.Config>;
export = markdownItTaskLists;
} }

View file

@ -1,17 +1,36 @@
import type { TextFloat } from './text';
const BUTTON_ELEMENT = ['button', 'a'] as const;
export type ButtonElement = (typeof BUTTON_ELEMENT)[number];
const BUTTON_TYPE = ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'] as const;
export type ButtonType = (typeof BUTTON_TYPE)[number];
const BUTTON_SIZE = ['small', 'medium', 'large'] as const;
export type ButtonSize = (typeof BUTTON_SIZE)[number];
export interface IconButtonProps {
active?: boolean;
disabled?: boolean;
float?: TextFloat;
icon?: string;
loading?: boolean;
outline?: boolean;
size?: ButtonSize;
text?: boolean;
type?: ButtonType;
}
export interface ButtonProps extends IconButtonProps {
block?: boolean;
element?: ButtonElement;
href?: string;
label?: string;
square?: boolean;
}
export type IN8nButton = { export type IN8nButton = {
attrs: { attrs: ButtonProps & {
label: string;
type?: 'primary' | 'secondary' | 'tertiary' | 'success' | 'warning' | 'danger';
size?: 'mini' | 'small' | 'medium' | 'large' | 'xlarge';
loading?: boolean;
disabled?: boolean;
outline?: boolean;
text?: boolean;
icon?: string;
block?: boolean;
active?: boolean;
float?: 'left' | 'right';
square?: boolean;
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'data-test-id'?: string; 'data-test-id'?: string;
}; };

View file

@ -0,0 +1,8 @@
const ICON_SIZE = ['xsmall', 'small', 'medium', 'large'] as const;
export type IconSize = (typeof ICON_SIZE)[number];
const ICON_COLOR = ['primary', 'danger', 'success', 'warning', 'text-base'] as const;
export type IconColor = (typeof ICON_COLOR)[number];
const ICON_ORIENTATION = ['horizontal', 'vertical'] as const;
export type IconOrientation = (typeof ICON_ORIENTATION)[number];

View file

@ -0,0 +1,20 @@
const TEXT_SIZE = ['xsmall', 'small', 'mini', 'medium', 'large', 'xlarge'] as const;
export type TextSize = (typeof TEXT_SIZE)[number];
const TEXT_COLOR = [
'primary',
'text-dark',
'text-base',
'text-light',
'text-xlight',
'danger',
'success',
'warning',
] as const;
export type TextColor = (typeof TEXT_COLOR)[number];
const TEXT_ALIGN = ['right', 'left', 'center'] as const;
export type TextAlign = (typeof TEXT_ALIGN)[number];
const TEXT_FLOAT = ['left', 'right'] as const;
export type TextFloat = (typeof TEXT_FLOAT)[number];

View file

@ -10,13 +10,15 @@
"incremental": false, "incremental": false,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"baseUrl": ".", "baseUrl": ".",
"types": ["webpack-env", "vitest/globals"], "types": ["vitest/globals"],
"typeRoots": ["@testing-library", "@types"], "typeRoots": ["@testing-library", "@types", "../../node_modules"],
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
}, },
"lib": ["esnext", "dom", "dom.iterable", "scripthost"], "lib": ["esnext", "dom", "dom.iterable", "scripthost"],
// TODO: remove all options below this line // TODO: remove all options below this line
"strict": false,
"noImplicitAny": false,
"noUnusedLocals": false, "noUnusedLocals": false,
"noImplicitReturns": false "noImplicitReturns": false
}, },