Put ArrayList in component library and storybook, add unit tests

This commit is contained in:
Catherine Luse 2022-11-17 22:11:39 -07:00
parent 215d2466ed
commit a279d22fc7
9 changed files with 559 additions and 377 deletions

View File

@ -0,0 +1,72 @@
import { mount } from '@vue/test-utils';
import { ArrayList } from './index';
import { _EDIT, _VIEW } from "@shell/config/query-params";
describe('ArrayList.vue', () => {
it('is empty', () => {
const wrapper = mount(ArrayList, {
propsData: {
value: [],
mode: _EDIT
},
});
const elements = wrapper.findAll('[data-testid^="array-list-box"]');
expect(elements.length).toBe(0);
});
it('shows an initial empty row', () => {
const wrapper = mount(ArrayList, {
propsData: {
value: [],
mode: _EDIT,
initialEmptyRow: true
},
});
const arrayListBoxes = wrapper.findAll('[data-testid="array-list-box"]');
expect(arrayListBoxes.length).toBe(1);
});
it('expands when the add button is clicked', async () => {
const wrapper = mount(ArrayList, {
propsData: {
value: [],
mode: _EDIT,
},
});
const arrayListButton = wrapper.find('[data-testid="array-list-button"]').element as HTMLElement;
await arrayListButton.click()
await arrayListButton.click()
const arrayListBoxes = wrapper.findAll('[data-testid="array-list-box"]')
expect(arrayListBoxes.length).toBe(2);
});
it('contracts when a delete button is clicked', async () => {
const wrapper = mount(ArrayList, {
propsData: {
value: ['string 1', 'string 2'],
mode: _EDIT,
},
});
const deleteButton = wrapper.get('[data-testid^="remove-item"]').element as HTMLElement;
await deleteButton.click()
const arrayListBoxes = wrapper.findAll('[data-testid="array-list-box"]')
expect(arrayListBoxes.length).toBe(1);
});
it('Add button is hidden in read-only mode', () => {
const wrapper = mount(ArrayList, {
propsData: {
value: ['read-only example'],
mode: _VIEW,
},
});
const arrayListButtons = wrapper.findAll('[data-testid="array-list-button"]')
expect(arrayListButtons.length).toBe(0);
});
});

View File

@ -0,0 +1,375 @@
<script lang='ts'>
import Vue, { PropType } from 'vue';
import debounce from 'lodash/debounce';
import { _EDIT, _VIEW } from '@shell/config/query-params';
import { removeAt } from '@shell/utils/array';
import TextAreaAutoGrow from '@components/Form/TextArea/TextAreaAutoGrow.vue';
import { clone } from '@shell/utils/object';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import { ArrayListData, ArrayListRow } from './types';
const DEFAULT_PROTIP =
'Tip: Paste lines into any list field for easy bulk entry';
export default Vue.extend({
components: { TextAreaAutoGrow, LabeledInput },
props: {
value: {
type: Array as PropType<Array<string>>,
default: () => {
return [];
},
},
mode: {
type: String,
default: _EDIT,
},
initialEmptyRow: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
protip: {
type: [String, Boolean],
default: DEFAULT_PROTIP,
},
showHeader: {
type: Boolean,
default: false,
},
valueLabel: {
type: String,
default: 'Value',
},
valuePlaceholder: {
type: String,
default: 'e.g. bar',
},
valueMultiline: {
type: Boolean,
default: false,
},
addLabel: {
type: String,
required: true,
},
addAllowed: {
type: Boolean,
default: true,
},
removeLabel: {
type: String,
required: true,
},
removeAllowed: {
type: Boolean,
default: true,
},
defaultAddValue: {
type: [String, Number, Object, Array],
default: '',
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
rules: {
default: () => [],
type: Array,
// we only want functions in the rules array
validator: (rules) =>
rules.every((rule) => ['function'].includes(typeof rule)),
},
},
data(): ArrayListData {
const input: string[] = (this.value || []).slice();
const rows: ArrayListRow[] = [];
for (const value of input) {
rows.push({ value });
}
if (!rows.length && this.initialEmptyRow) {
const value = this.defaultAddValue ? clone(this.defaultAddValue) : '';
rows.push({ value });
}
return { rows, lastUpdateWasFromValue: false };
},
computed: {
isView(): boolean {
return this.mode === _VIEW;
},
showAdd(): boolean {
return this.addAllowed;
},
showRemove(): boolean {
return this.removeAllowed;
},
isDefaultProtip(): boolean {
return this.protip === DEFAULT_PROTIP;
},
showProtip(): boolean {
if (this.protip && !this.isDefaultProtip) {
return true;
}
return !this.valueMultiline && !!this.protip;
},
},
watch: {
value() {
this.lastUpdateWasFromValue = true;
this.rows = (this.value || []).map((v) => ({ value: v }));
},
rows: {
deep: true,
handler() {
// lastUpdateWasFromValue is used to break a cycle where when rows are updated
// this was called which then forced rows to updated again
if (!this.lastUpdateWasFromValue) {
this.queueUpdate();
}
this.lastUpdateWasFromValue = false;
},
},
},
created() {
debounce(this.update, 50);
},
methods: {
queueUpdate() {
debounce(this.update, 50);
},
add() {
this.rows.push({ value: clone(this.defaultAddValue) });
if (this.defaultAddValue) {
this.queueUpdate();
}
this.$nextTick(() => {
const inputs: any = this.$refs.value;
if (inputs && inputs.length > 0) {
inputs[inputs.length - 1].focus();
}
this.$emit('add');
});
},
/**
* Remove item and emits removed row and its own index value
*/
remove(row: ArrayListRow, index: number): void {
this.$emit('remove', { row, index });
removeAt(this.rows, index);
this.queueUpdate();
},
update() {
if (this.isView) {
return;
}
const out = [];
for (const row of this.rows) {
const trim = !this.valueMultiline && typeof row.value === 'string';
const value = trim ? row.value.trim() : row.value;
if (typeof value !== 'undefined') {
out.push(value);
}
}
this.$emit('input', out);
},
onPaste(index: number, event: any) {
if (this.valueMultiline) {
return;
}
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
const split = text.split('\n').map((value: string) => ({ value }));
this.rows.splice(index, 1, ...split);
this.update();
},
},
});
</script>
<template>
<div>
<div v-if="title" class="clearfix">
<slot name="title">
<h3>
{{ title }}
<i v-if="showProtip" v-tooltip="protip" class="icon icon-info" />
</h3>
</slot>
</div>
<template v-if="rows.length">
<div v-if="showHeader">
<slot name="column-headers">
<label class="value text-label mb-10">
{{ valueLabel }}
</label>
</slot>
</div>
<div
v-for="(row, idx) in rows"
:key="idx"
class="box"
data-testid="array-list-box"
>
<slot
name="columns"
:queueUpdate="queueUpdate"
:i="idx"
:rows="rows"
:row="row"
:mode="mode"
:isView="isView"
>
<div class="value">
<slot
name="value"
:row="row"
:mode="mode"
:isView="isView"
:queue-update="queueUpdate"
>
<TextAreaAutoGrow
v-if="valueMultiline"
ref="value"
v-model="row.value"
:placeholder="valuePlaceholder"
:mode="mode"
:disabled="disabled"
@paste="onPaste(idx, $event)"
@input="queueUpdate"
/>
<LabeledInput
v-else-if="rules.length > 0"
ref="value"
v-model="row.value"
:placeholder="valuePlaceholder"
:disabled="isView || disabled"
:rules="rules"
:compact="false"
@paste="onPaste(idx, $event)"
@input="queueUpdate"
/>
<input
v-else
ref="value"
v-model="row.value"
:placeholder="valuePlaceholder"
:disabled="isView || disabled"
@paste="onPaste(idx, $event)"
@input="queueUpdate"
/>
</slot>
</div>
</slot>
<div v-if="showRemove" class="remove">
<slot
name="remove-button"
:remove="() => remove(row, idx)"
:i="idx"
:row="row"
>
<button
type="button"
:disabled="isView"
class="btn role-link"
:data-testid="`remove-item-${idx}`"
@click="remove(row, idx)"
>
{{ removeLabel }}
</button>
</slot>
</div>
</div>
</template>
<div v-else-if="mode === 'view'" class="text-muted">&mdash;</div>
<div v-else>
<slot name="empty" />
</div>
<div v-if="showAdd && !isView" class="footer">
<slot v-if="showAdd" name="add" :add="add">
<button
data-testid="array-list-button"
type="button"
class="btn role-tertiary add"
:disabled="loading"
@click="add()"
>
<i v-if="loading" class="mr-5 icon icon-spinner icon-spin icon-lg" />
{{ addLabel }}
</button>
</slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.title {
margin-bottom: 10px;
}
.box {
display: grid;
grid-template-columns: auto $array-list-remove-margin;
align-items: center;
margin-bottom: 10px;
.value {
flex: 1;
INPUT {
height: $input-height;
}
}
}
.remove {
text-align: right;
}
.footer {
.protip {
float: right;
padding: 5px 0;
}
}
</style>

View File

@ -0,0 +1 @@
export { default as ArrayList } from './ArrayList.vue';

View File

@ -0,0 +1,8 @@
export type ArrayListRow = {
value: string;
}
export type ArrayListData = {
lastUpdateWasFromValue: boolean;
rows: Array<ArrayListRow>,
}

View File

@ -3,3 +3,4 @@ export * from './LabeledInput';
export * from './Radio';
export * from './TextArea';
export * from './ToggleSwitch';
export * from './ArrayList';

View File

@ -11,3 +11,12 @@ export const RadioGroup: VueConstructor;
export const StringList: VueConstructor;
export const TextAreaAutoGrow: VueConstructor;
export const ToggleSwitch: VueConstructor;
type ArrayListRow = {
value: string;
}
export type ArrayListData = {
lastUpdateWasFromValue: boolean;
rows: Array<ArrayListRow>,
}

View File

@ -1,396 +1,49 @@
<script>
import debounce from 'lodash/debounce';
import { _EDIT, _VIEW } from '@shell/config/query-params';
import { removeAt } from '@shell/utils/array';
import { TextAreaAutoGrow } from '@components/Form/TextArea';
import { clone } from '@shell/utils/object';
import { LabeledInput } from '@components/Form/LabeledInput';
import ArrayList from '@components/Form/ArrayList/ArrayList';
const DEFAULT_PROTIP = 'Tip: Paste lines into any list field for easy bulk entry';
/*
The purpose of this component is to allow the ArrayList component
to use translations from Rancher's Vuex store, while also separating out
that dependency on the store so that the ArrayList can be added to the Rancher
component library.
*/
export default {
components: { TextAreaAutoGrow, LabeledInput },
props: {
value: {
type: Array,
default: null,
},
mode: {
type: String,
default: _EDIT,
},
initialEmptyRow: {
type: Boolean,
default: false,
},
title: {
type: String,
default: ''
},
protip: {
type: [String, Boolean],
default: DEFAULT_PROTIP,
},
showHeader: {
type: Boolean,
default: false,
},
valueLabel: {
type: String,
default: 'Value',
},
valuePlaceholder: {
type: String,
default: 'e.g. bar'
},
valueMultiline: {
type: Boolean,
default: false,
},
name: 'ArrayListWrapper',
components: { ArrayList },
inheritAttrs: false,
props: {
addLabel: {
type: String,
default() {
return this.$store.getters['i18n/t']('generic.add');
},
},
addAllowed: {
type: Boolean,
default: true,
},
removeLabel: {
type: String,
default() {
return this.$store.getters['i18n/t']('generic.remove');
},
},
removeAllowed: {
type: Boolean,
default: true,
},
defaultAddValue: {
type: [String, Number, Object, Array],
default: ''
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false,
},
rules: {
default: () => [],
type: Array,
// we only want functions in the rules array
validator: rules => rules.every(rule => ['function'].includes(typeof rule))
}
},
data() {
const input = (this.value || []).slice();
const rows = [];
for ( const value of input ) {
rows.push({ value });
}
if ( !rows.length && this.initialEmptyRow ) {
const value = this.defaultAddValue ? clone(this.defaultAddValue) : '';
rows.push({ value });
}
return { rows, lastUpdateWasFromValue: false };
},
computed: {
isView() {
return this.mode === _VIEW;
},
showAdd() {
return this.addAllowed;
},
showRemove() {
return this.removeAllowed;
},
isDefaultProtip() {
return this.protip === DEFAULT_PROTIP;
},
showProtip() {
if (this.protip && !this.isDefaultProtip) {
return true;
}
return !this.valueMultiline && this.protip;
}
},
watch: {
value() {
this.lastUpdateWasFromValue = true;
this.rows = (this.value || []).map(v => ({ value: v }));
},
rows: {
deep: true,
handler(newValue, oldValue) {
// lastUpdateWasFromValue is used to break a cycle where when rows are updated
// this was called which then forced rows to updated again
if (!this.lastUpdateWasFromValue) {
this.queueUpdate();
}
this.lastUpdateWasFromValue = false;
}
}
},
created() {
this.queueUpdate = debounce(this.update, 50);
},
methods: {
add() {
this.rows.push({ value: clone(this.defaultAddValue) });
if (this.defaultAddValue) {
this.queueUpdate();
}
this.$nextTick(() => {
const inputs = this.$refs.value;
if ( inputs && inputs.length > 0 ) {
inputs[inputs.length - 1].focus();
}
this.$emit('add');
});
},
/**
* Remove item and emits removed row and its own index value
*/
remove(row, index) {
this.$emit('remove', { row, index });
removeAt(this.rows, index);
this.queueUpdate();
},
update() {
if ( this.isView ) {
return;
}
const out = [];
for ( const row of this.rows ) {
const trim = !this.valueMultiline && (typeof row.value === 'string');
const value = trim ? row.value.trim() : row.value;
if ( typeof value !== 'undefined' ) {
out.push(value);
}
}
this.$emit('input', out);
},
onPaste(index, event) {
if (this.valueMultiline) {
return;
}
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
const split = text.split('\n').map(value => ({ value }));
this.rows.splice(index, 1, ...split);
this.update();
}
},
}
};
</script>
<template>
<div>
<div
v-if="title"
class="clearfix"
>
<slot name="title">
<h3>
{{ title }}
<i
v-if="showProtip"
v-tooltip="protip"
class="icon icon-info"
/>
</h3>
</slot>
</div>
<template v-if="rows.length">
<div v-if="showHeader">
<slot name="column-headers">
<label class="value text-label mb-10">
{{ valueLabel }}
</label>
</slot>
</div>
<div
v-for="(row, idx) in rows"
:key="idx"
class="box"
>
<slot
name="columns"
:queueUpdate="queueUpdate"
:i="idx"
:rows="rows"
:row="row"
:mode="mode"
:isView="isView"
>
<div class="value">
<slot
name="value"
:row="row"
:mode="mode"
:isView="isView"
:queue-update="queueUpdate"
>
<TextAreaAutoGrow
v-if="valueMultiline"
ref="value"
v-model="row.value"
:placeholder="valuePlaceholder"
:mode="mode"
:disabled="disabled"
@paste="onPaste(idx, $event)"
@input="queueUpdate"
/>
<LabeledInput
v-else-if="rules.length > 0"
ref="value"
v-model="row.value"
:placeholder="valuePlaceholder"
:disabled="isView || disabled"
:rules="rules"
:compact="false"
@paste="onPaste(idx, $event)"
@input="queueUpdate"
/>
<input
v-else
ref="value"
v-model="row.value"
:placeholder="valuePlaceholder"
:disabled="isView || disabled"
@paste="onPaste(idx, $event)"
@input="queueUpdate"
>
</slot>
</div>
</slot>
<div
v-if="showRemove"
class="remove"
>
<slot
name="remove-button"
:remove="() => remove(row, idx)"
:i="idx"
:row="row"
>
<button
type="button"
:disabled="isView"
class="btn role-link"
:data-testid="`remove-item-${idx}`"
@click="remove(row, idx)"
>
{{ removeLabel }}
</button>
</slot>
</div>
</div>
</template>
<div
v-else-if="mode==='view'"
class="text-muted"
>
&mdash;
</div>
<div v-else>
<slot name="empty" />
</div>
<div
v-if="showAdd && !isView"
class="footer"
<array-list
v-bind="$attrs"
:add-label="addLabel"
:remove-label="removeLabel"
v-on="$listeners"
>
<template
v-for="(_, slot) of $scopedSlots"
v-slot:[slot]="scope"
>
<slot
v-if="showAdd"
name="add"
:add="add"
>
<button
type="button"
class="btn role-tertiary add"
:disabled="loading"
data-testid="add-item"
@click="add()"
>
<i
v-if="loading"
class="mr-5 icon icon-spinner icon-spin icon-lg"
/>
{{ addLabel }}
</button>
</slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.title {
margin-bottom: 10px;
}
.box {
display: grid;
grid-template-columns: auto $array-list-remove-margin;
align-items: center;
margin-bottom: 10px;
.value {
flex: 1;
INPUT {
height: $input-height;
}
}
}
.remove {
text-align: right;
}
.footer {
.protip {
float: right;
padding: 5px 0;
}
}
</style>
:name="slot"
v-bind="scope"
/>
</template>
</array-list>
</template>

View File

@ -4,7 +4,7 @@ import ArrayListGrouped from '@shell/components/form/ArrayListGrouped.vue';
describe('component: ArrayListGrouped', () => {
it('should display enabled add button', () => {
const wrapper = mount(ArrayListGrouped);
const button = wrapper.find('[data-testid^="add-item"]').element as HTMLInputElement;
const button = wrapper.find('[data-testid^="array-list-button"]').element as HTMLInputElement;
expect(button.disabled).toBe(false);
});
@ -14,7 +14,7 @@ describe('component: ArrayListGrouped', () => {
mocks: { propsData: { value: ['a', 'b'] } },
slots: { default: '<div id="test"/>' }
});
const button = wrapper.find('[data-testid^="add-item"]');
const button = wrapper.find('[data-testid^="array-list-button"]');
await button.trigger('click');
await button.trigger('click');
@ -29,7 +29,7 @@ describe('component: ArrayListGrouped', () => {
slots: { default: '<div id="test"/>' }
});
await wrapper.find('[data-testid^="add-item"]').trigger('click');
await wrapper.find('[data-testid^="array-list-button"]').trigger('click');
await wrapper.find('[data-testid^="remove-item"]').trigger('click');
const elements = wrapper.findAll('#test');

View File

@ -0,0 +1,63 @@
import { Meta, Canvas, Story, ArgsTable, Source } from '@storybook/addon-docs';
import ArrayList from '@/pkg/rancher-components/src/components/Form/ArrayList/ArrayList';
import { _EDIT, _VIEW } from "@shell/config/query-params";
export const Template = (args, { argTypes }) => ({
components: { ArrayList },
props: Object.keys(argTypes),
template: '<ArrayList v-bind="$props" />',
})
<Meta
title="Components/Form/ArrayList"
decorators={[]}
/>
# ArrayList
The ArrayList is used to input a list of strings. The list is of variable length.
## Default Empty ArrayList (Button only)
<Canvas>
<Story
name="Empty"
>
{Template.bind({})}
</Story>
</Canvas>
## Showing an Initial Empty Row
<Canvas>
<Story
name="Showing an initial empty row"
args={{
initialEmptyRow: true
}}>
{Template.bind({})}
</Story>
</Canvas>
## Read-only Mode
<Canvas>
<Story
name="Disabled"
args={{
value: ['string 1', 'string 2'],
mode: _VIEW
}}>
{Template.bind({})}
</Story>
</Canvas>
### Import
<Source
language='ts'
code={`
import ArrayList from '@/pkg/rancher-components/src/components/Form/ArrayList/ArrayList';
`}
/>
### Props table
<ArgsTable of={ArrayList} />