teableio/teable

View on GitHub
apps/nextjs-app/src/features/app/components/field-setting/options/SelectOptions/SelectOptions.tsx

Summary

Maintainability
A
55 mins
Test Coverage
import type { DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { ISelectFieldChoice, ISelectFieldOptions } from '@teable/core';
import { ColorUtils } from '@teable/core';
import { DraggableHandle, Plus, Trash } from '@teable/icons';
import { cn } from '@teable/ui-lib/shadcn';
import { Button } from '@teable/ui-lib/shadcn/ui/button';
import { useTranslation } from 'next-i18next';
import { useMemo, useRef } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
import { Virtuoso } from 'react-virtuoso';
import { HeightPreservingItem } from '@/features/app/blocks/view/kanban/components/KanbanStack';
import { tableConfig } from '@/features/i18n/table.config';
import { ChoiceItem } from './ChoiceItem';
import { SelectDefaultValue } from './SelectDefaultValue';

const getChoiceId = (choice: ISelectFieldChoice, index: number) => {
  const { id, color, name } = choice;
  return id ?? `${color}-${name}-${index}`;
};

export const SelectOptions = (props: {
  isMultiple: boolean;
  options: Partial<ISelectFieldOptions> | undefined;
  isLookup?: boolean;
  onChange?: (options: Partial<ISelectFieldOptions>) => void;
}) => {
  const { isMultiple, options, isLookup, onChange } = props;
  const virtuosoRef = useRef<VirtuosoHandle>(null);
  const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
  const { t } = useTranslation(tableConfig.i18nNamespaces);

  const choices = useMemo(() => options?.choices ?? [], [options?.choices]);

  const updateOptionChange = (index: number, key: keyof ISelectFieldChoice, value: string) => {
    const newChoice = choices.map((v, i) => {
      if (i === index) {
        return {
          ...v,
          [key]: value,
        };
      }
      return v;
    });
    onChange?.({ choices: newChoice });
  };

  const onDefaultValueChange = (defaultValue: string | string[] | undefined) => {
    onChange?.({ defaultValue });
  };

  const deleteChoice = (index: number) => {
    onChange?.({
      choices: choices.filter((_, i) => i !== index),
    });
  };

  const addOption = () => {
    const existColors = choices.map((v) => v.color);
    const choice = {
      name: '',
      color: ColorUtils.randomColor(existColors)[0],
    } as ISelectFieldChoice;

    const newChoices = [...choices, choice];
    onChange?.({ choices: newChoices });
    setTimeout(() => {
      virtuosoRef.current?.scrollToIndex({ index: 'LAST' });
      setTimeout(() => inputRefs.current[choices.length]?.focus(), 150);
    });
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter' && !isLookup) {
      addOption();
    }
  };

  const onDragEnd = async (result: DropResult) => {
    const { source, destination } = result;

    if (!destination) return;

    const { index: from } = source;
    const { index: to } = destination;
    const list = [...choices];
    const [choice] = list.splice(from, 1);

    list.splice(to, 0, choice);

    onChange?.({ choices: list });
  };

  return (
    <div className="flex grow flex-col space-y-2">
      <div className="grow" style={{ maxHeight: choices.length * 36 }}>
        <DragDropContext onDragEnd={onDragEnd}>
          <Droppable
            droppableId={'select-choice-container'}
            mode="virtual"
            renderClone={(provided, snapshot, rubric) => {
              const choice = choices[rubric.source.index];
              const { draggableProps } = provided;
              return (
                <div
                  ref={provided.innerRef}
                  {...draggableProps}
                  className={cn('py-1', isLookup && 'cursor-default')}
                >
                  <div className="flex items-center">
                    {!isLookup && <DraggableHandle className="mr-1 size-4 cursor-grabbing" />}
                    <ChoiceItem
                      choice={choice}
                      readonly={isLookup}
                      onChange={(key, value) => updateOptionChange(0, key, value)}
                      onKeyDown={onKeyDown}
                      onInputRef={(el) => (inputRefs.current[0] = el)}
                    />
                    {!isLookup && (
                      <Button
                        variant={'ghost'}
                        className="size-6 rounded-full p-0 focus-visible:ring-transparent focus-visible:ring-offset-0"
                        onClick={() => deleteChoice(0)}
                      >
                        <Trash className="size-4" />
                      </Button>
                    )}
                  </div>
                </div>
              );
            }}
          >
            {(provided) => (
              <Virtuoso
                ref={virtuosoRef}
                scrollerRef={provided.innerRef as never}
                className="size-full"
                totalCount={choices.length}
                overscan={5}
                components={{
                  Item: HeightPreservingItem as never,
                }}
                itemContent={(index) => {
                  const choice = choices[index];
                  if (choice == null) {
                    return null;
                  }
                  return (
                    <Draggable
                      draggableId={getChoiceId(choice, index)}
                      index={index}
                      key={getChoiceId(choice, index)}
                    >
                      {(draggableProvided) => {
                        const { draggableProps, dragHandleProps } = draggableProvided;

                        return (
                          <div
                            ref={draggableProvided.innerRef}
                            {...draggableProps}
                            className={cn('py-1', isLookup && 'cursor-default')}
                          >
                            <div className="flex items-center">
                              {!isLookup && (
                                <div {...dragHandleProps} className="mr-1 size-4">
                                  <DraggableHandle className="size-4 cursor-grabbing" />
                                </div>
                              )}
                              <ChoiceItem
                                choice={choice}
                                readonly={isLookup}
                                onChange={(key, value) => updateOptionChange(index, key, value)}
                                onKeyDown={onKeyDown}
                                onInputRef={(el) => (inputRefs.current[index] = el)}
                              />
                              {!isLookup && (
                                <Button
                                  variant={'ghost'}
                                  className="size-6 rounded-full p-0 focus-visible:ring-transparent focus-visible:ring-offset-0"
                                  onClick={() => deleteChoice(index)}
                                >
                                  <Trash className="size-4" />
                                </Button>
                              )}
                            </div>
                          </div>
                        );
                      }}
                    </Draggable>
                  );
                }}
              />
            )}
          </Droppable>
        </DragDropContext>
      </div>
      {!isLookup && (
        <>
          <div className="mt-1 shrink-0">
            <Button
              className="w-full gap-2 text-sm font-normal"
              size={'sm'}
              variant={'outline'}
              onClick={addOption}
            >
              <Plus className="size-4" />
              {t('table:field.editor.addOption')}
            </Button>
          </div>
          <SelectDefaultValue
            isMultiple={isMultiple}
            onChange={onDefaultValueChange}
            options={options}
          />
        </>
      )}
    </div>
  );
};