From c0a47271147486fed147756297ceadcadfce209c Mon Sep 17 00:00:00 2001
From: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Date: Mon, 22 Nov 2021 18:13:40 +0200
Subject: [PATCH] feat(app): introduce input list component [EE-2003] (#6123)

---
 app/assets/css/index.js                       |   6 +-
 app/portainer/components/Button/AddButton.tsx |   4 +-
 .../form-components/Input/BaseInput.tsx       |   2 +-
 .../form-components/Input/Select.tsx          |   4 +-
 .../form-components/Input/TextInput.tsx       |   3 +-
 .../InputList/InputList.module.css            |  30 +++
 .../InputList/InputList.stories.tsx           |  90 +++++++++
 .../form-components/InputList/InputList.tsx   | 183 ++++++++++++++++++
 .../form-components/InputList/index.ts        |   1 +
 .../form-components/InputList/utils.test.ts   |  23 +++
 .../form-components/InputList/utils.ts        |  37 ++++
 yarn.lock                                     |   2 +-
 12 files changed, 376 insertions(+), 9 deletions(-)
 create mode 100644 app/portainer/components/form-components/InputList/InputList.module.css
 create mode 100644 app/portainer/components/form-components/InputList/InputList.stories.tsx
 create mode 100644 app/portainer/components/form-components/InputList/InputList.tsx
 create mode 100644 app/portainer/components/form-components/InputList/index.ts
 create mode 100644 app/portainer/components/form-components/InputList/utils.test.ts
 create mode 100644 app/portainer/components/form-components/InputList/utils.ts

diff --git a/app/assets/css/index.js b/app/assets/css/index.js
index e4a01560b..c48ded8c2 100644
--- a/app/assets/css/index.js
+++ b/app/assets/css/index.js
@@ -1,6 +1,3 @@
-import './rdash.css';
-import './app.css';
-
 import 'ui-select/dist/select.css';
 import 'bootstrap/dist/css/bootstrap.css';
 import '@fortawesome/fontawesome-free/css/brands.css';
@@ -17,5 +14,8 @@ import 'angular-moment-picker/dist/angular-moment-picker.min.css';
 import 'angular-multiselect/isteven-multi-select.css';
 import 'spinkit/spinkit.min.css';
 
+import './rdash.css';
+import './app.css';
+
 import './theme.css';
 import './vendor-override.css';
diff --git a/app/portainer/components/Button/AddButton.tsx b/app/portainer/components/Button/AddButton.tsx
index e95718ddc..2f97159a6 100644
--- a/app/portainer/components/Button/AddButton.tsx
+++ b/app/portainer/components/Button/AddButton.tsx
@@ -3,14 +3,16 @@ import clsx from 'clsx';
 import styles from './AddButton.module.css';
 
 export interface Props {
+  className?: string;
   label: string;
   onClick: () => void;
 }
 
-export function AddButton({ label, onClick }: Props) {
+export function AddButton({ label, onClick, className }: Props) {
   return (
     <button
       className={clsx(
+        className,
         'label',
         'label-default',
         'interactive',
diff --git a/app/portainer/components/form-components/Input/BaseInput.tsx b/app/portainer/components/form-components/Input/BaseInput.tsx
index 20d342a41..739f394b1 100644
--- a/app/portainer/components/form-components/Input/BaseInput.tsx
+++ b/app/portainer/components/form-components/Input/BaseInput.tsx
@@ -33,7 +33,7 @@ export function BaseInput({
       readOnly={readonly}
       required={required}
       type={type}
-      className={clsx(className, 'form-control')}
+      className={clsx('form-control', className)}
       onChange={(e) => onChange(e.target.value)}
       rows={rows}
     />
diff --git a/app/portainer/components/form-components/Input/Select.tsx b/app/portainer/components/form-components/Input/Select.tsx
index a4794f03c..d6776bd79 100644
--- a/app/portainer/components/form-components/Input/Select.tsx
+++ b/app/portainer/components/form-components/Input/Select.tsx
@@ -31,7 +31,9 @@ export function Select<T extends number | string>({
       onChange={handleChange}
     >
       {options.map((item) => (
-        <option value={item.value}>{item.label}</option>
+        <option value={item.value} key={item.value}>
+          {item.label}
+        </option>
       ))}
     </select>
   );
diff --git a/app/portainer/components/form-components/Input/TextInput.tsx b/app/portainer/components/form-components/Input/TextInput.tsx
index a540abeb6..69e0301e0 100644
--- a/app/portainer/components/form-components/Input/TextInput.tsx
+++ b/app/portainer/components/form-components/Input/TextInput.tsx
@@ -1,4 +1,3 @@
-import clsx from 'clsx';
 import { HTMLInputTypeAttribute } from 'react';
 
 import { BaseInput } from './BaseInput';
@@ -23,7 +22,7 @@ export function TextInput({
     <BaseInput
       id={id}
       type={type}
-      className={clsx(className, 'form-control')}
+      className={className}
       value={value}
       onChange={onChange}
       disabled={disabled}
diff --git a/app/portainer/components/form-components/InputList/InputList.module.css b/app/portainer/components/form-components/InputList/InputList.module.css
new file mode 100644
index 000000000..7be4f1390
--- /dev/null
+++ b/app/portainer/components/form-components/InputList/InputList.module.css
@@ -0,0 +1,30 @@
+.items {
+  margin-top: 10px;
+}
+
+.items > * + * {
+  margin-top: 2px;
+}
+
+.label {
+  text-align: left;
+  font-size: 0.9em;
+  padding-top: 7px;
+  margin-bottom: 0;
+  display: inline-block;
+  max-width: 100%;
+  font-weight: 700;
+}
+
+.item-line {
+  display: flex;
+}
+
+.item-actions {
+  display: flex;
+  margin-left: 2px;
+}
+
+.default-item {
+  width: 100% !important;
+}
diff --git a/app/portainer/components/form-components/InputList/InputList.stories.tsx b/app/portainer/components/form-components/InputList/InputList.stories.tsx
new file mode 100644
index 000000000..4b5f1f8d4
--- /dev/null
+++ b/app/portainer/components/form-components/InputList/InputList.stories.tsx
@@ -0,0 +1,90 @@
+import { Meta } from '@storybook/react';
+import { useState } from 'react';
+
+import { NumberInput, Select } from '../Input';
+
+import { DefaultType, InputList } from './InputList';
+
+const meta: Meta = {
+  title: 'InputList',
+  component: InputList,
+};
+
+export default meta;
+
+export function Defaults() {
+  const [values, setValues] = useState<DefaultType[]>([{ value: '' }]);
+
+  return (
+    <InputList
+      label="default example"
+      value={values}
+      onChange={(value) => setValues(value)}
+    />
+  );
+}
+
+interface ListWithSelectItem {
+  value: number;
+  select: string;
+  id: number;
+}
+
+interface ListWithInputAndSelectArgs {
+  label: string;
+  movable: boolean;
+  tooltip: string;
+}
+export function ListWithInputAndSelect({
+  label,
+  movable,
+  tooltip,
+}: ListWithInputAndSelectArgs) {
+  const [values, setValues] = useState<ListWithSelectItem[]>([
+    { value: 0, select: '', id: 0 },
+  ]);
+
+  return (
+    <InputList<ListWithSelectItem>
+      label={label}
+      onChange={setValues}
+      value={values}
+      item={SelectAndInputItem}
+      itemKeyGetter={(item) => item.id}
+      movable={movable}
+      itemBuilder={() => ({ value: 0, select: '', id: values.length })}
+      tooltip={tooltip}
+    />
+  );
+}
+
+ListWithInputAndSelect.args = {
+  label: 'List with select and input',
+  movable: false,
+  tooltip: '',
+};
+
+function SelectAndInputItem({
+  item,
+  onChange,
+}: {
+  item: ListWithSelectItem;
+  onChange: (value: ListWithSelectItem) => void;
+}) {
+  return (
+    <div>
+      <NumberInput
+        value={item.value}
+        onChange={(value: number) => onChange({ ...item, value })}
+      />
+      <Select
+        onChange={(select: string) => onChange({ ...item, select })}
+        options={[
+          { label: 'option1', value: 'option1' },
+          { label: 'option2', value: 'option2' },
+        ]}
+        value={item.select}
+      />
+    </div>
+  );
+}
diff --git a/app/portainer/components/form-components/InputList/InputList.tsx b/app/portainer/components/form-components/InputList/InputList.tsx
new file mode 100644
index 000000000..5855361a7
--- /dev/null
+++ b/app/portainer/components/form-components/InputList/InputList.tsx
@@ -0,0 +1,183 @@
+import { ComponentType } from 'react';
+import clsx from 'clsx';
+
+import { AddButton, Button } from '@/portainer/components/Button';
+import { Tooltip } from '@/portainer/components/Tooltip';
+
+import { TextInput } from '../Input';
+
+import styles from './InputList.module.css';
+import { arrayMove } from './utils';
+
+interface ItemProps<T> {
+  item: T;
+  onChange(value: T): void;
+}
+type Key = string | number;
+type ChangeType = 'delete' | 'create' | 'update';
+export type DefaultType = { value: string };
+
+type OnChangeEvent<T> =
+  | {
+      item: T;
+      type: ChangeType;
+    }
+  | {
+      type: 'move';
+      fromIndex: number;
+      to: number;
+    };
+
+interface Props<T> {
+  label: string;
+  value: T[];
+  onChange(value: T[], e: OnChangeEvent<T>): void;
+  itemBuilder?(): T;
+  item?: ComponentType<ItemProps<T>>;
+  tooltip?: string;
+  addLabel?: string;
+  itemKeyGetter?(item: T, index: number): Key;
+  movable?: boolean;
+}
+
+export function InputList<T = DefaultType>({
+  label,
+  value,
+  onChange,
+  itemBuilder = (defaultItemBuilder as unknown) as () => T,
+  item = (DefaultItem as unknown) as ComponentType<ItemProps<T>>,
+  tooltip,
+  addLabel = 'Add item',
+  itemKeyGetter = (item: T, index: number) => index,
+  movable,
+}: Props<T>) {
+  const Item = item;
+
+  return (
+    <div className={clsx('form-group', styles.root)}>
+      <div className={clsx('col-sm-12', styles.header)}>
+        <div className={clsx('control-label text-left', styles.label)}>
+          {label}
+          {tooltip && <Tooltip message={tooltip} />}
+        </div>
+        <AddButton
+          label={addLabel}
+          className="space-left"
+          onClick={handleAdd}
+        />
+      </div>
+
+      <div className={clsx('col-sm-12 form-inline', styles.items)}>
+        {value.map((item, index) => {
+          const key = itemKeyGetter(item, index);
+
+          return (
+            <div key={key} className={clsx(styles.itemLine)}>
+              <Item
+                item={item}
+                onChange={(value: T) => handleChangeItem(key, value)}
+              />
+              <div className={styles.itemActions}>
+                {movable && (
+                  <>
+                    <Button
+                      size="small"
+                      disabled={index === 0}
+                      onClick={() => handleMoveUp(index)}
+                    >
+                      <i className="fa fa-arrow-up" aria-hidden="true" />
+                    </Button>
+                    <Button
+                      size="small"
+                      type="button"
+                      disabled={index === value.length - 1}
+                      onClick={() => handleMoveDown(index)}
+                    >
+                      <i className="fa fa-arrow-down" aria-hidden="true" />
+                    </Button>
+                  </>
+                )}
+                <Button
+                  color="danger"
+                  size="small"
+                  onClick={() => handleRemoveItem(key, item)}
+                >
+                  <i className="fa fa-trash" aria-hidden="true" />
+                </Button>
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+
+  function handleMoveUp(index: number) {
+    if (index <= 0) {
+      return;
+    }
+    handleMove(index, index - 1);
+  }
+
+  function handleMoveDown(index: number) {
+    if (index >= value.length - 1) {
+      return;
+    }
+    handleMove(index, index + 1);
+  }
+
+  function handleMove(from: number, to: number) {
+    if (!movable) {
+      return;
+    }
+
+    onChange(arrayMove(value, from, to), {
+      type: 'move',
+      fromIndex: from,
+      to,
+    });
+  }
+
+  function handleRemoveItem(key: Key, item: T) {
+    onChange(
+      value.filter((item, index) => {
+        const itemKey = itemKeyGetter(item, index);
+        return itemKey !== key;
+      }),
+      { type: 'delete', item }
+    );
+  }
+
+  function handleAdd() {
+    const newItem = itemBuilder();
+    onChange([...value, newItem], { type: 'create', item: newItem });
+  }
+
+  function handleChangeItem(key: Key, newItemValue: T) {
+    const newItems = value.map((item, index) => {
+      const itemKey = itemKeyGetter(item, index);
+      if (itemKey !== key) {
+        return item;
+      }
+      return newItemValue;
+    });
+    onChange(newItems, {
+      type: 'update',
+      item: newItemValue,
+    });
+  }
+}
+
+function defaultItemBuilder(): DefaultType {
+  return { value: '' };
+}
+
+function DefaultItem({ item, onChange }: ItemProps<DefaultType>) {
+  return (
+    <TextInput
+      value={item.value}
+      onChange={(value: string) => onChange({ value })}
+      className={styles.defaultItem}
+    />
+  );
+}
diff --git a/app/portainer/components/form-components/InputList/index.ts b/app/portainer/components/form-components/InputList/index.ts
new file mode 100644
index 000000000..6d613f13a
--- /dev/null
+++ b/app/portainer/components/form-components/InputList/index.ts
@@ -0,0 +1 @@
+export { InputList } from './InputList';
diff --git a/app/portainer/components/form-components/InputList/utils.test.ts b/app/portainer/components/form-components/InputList/utils.test.ts
new file mode 100644
index 000000000..cf350e649
--- /dev/null
+++ b/app/portainer/components/form-components/InputList/utils.test.ts
@@ -0,0 +1,23 @@
+import { arrayMove } from './utils';
+
+it('moves items in an array', () => {
+  expect(arrayMove(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b']);
+  expect(
+    arrayMove(
+      [
+        { name: 'Fred' },
+        { name: 'Barney' },
+        { name: 'Wilma' },
+        { name: 'Betty' },
+      ],
+      2,
+      1
+    )
+  ).toEqual([
+    { name: 'Fred' },
+    { name: 'Wilma' },
+    { name: 'Barney' },
+    { name: 'Betty' },
+  ]);
+  expect(arrayMove([1, 2, 3], 2, 1)).toEqual([1, 3, 2]);
+});
diff --git a/app/portainer/components/form-components/InputList/utils.ts b/app/portainer/components/form-components/InputList/utils.ts
new file mode 100644
index 000000000..41b7fc307
--- /dev/null
+++ b/app/portainer/components/form-components/InputList/utils.ts
@@ -0,0 +1,37 @@
+export function arrayMove<T>(array: Array<T>, from: number, to: number) {
+  if (!checkValidIndex(array, from) || !checkValidIndex(array, to)) {
+    throw new Error('index is out of bounds');
+  }
+
+  const item = array[from];
+  const { length } = array;
+
+  const diff = from - to;
+
+  if (diff > 0) {
+    // move left
+    return [
+      ...array.slice(0, to),
+      item,
+      ...array.slice(to, from),
+      ...array.slice(from + 1, length),
+    ];
+  }
+
+  if (diff < 0) {
+    // move right
+    const targetIndex = to + 1;
+    return [
+      ...array.slice(0, from),
+      ...array.slice(from + 1, targetIndex),
+      item,
+      ...array.slice(targetIndex, length),
+    ];
+  }
+
+  return [...array];
+
+  function checkValidIndex<T>(array: Array<T>, index: number) {
+    return index >= 0 && index <= array.length;
+  }
+}
diff --git a/yarn.lock b/yarn.lock
index d8898b5a3..1bde71174 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12132,7 +12132,7 @@ locate-path@^6.0.0:
   dependencies:
     p-locate "^5.0.0"
 
-lodash-es@^4.17.21:
+lodash-es@^4.17.15, lodash-es@^4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
   integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==