teableio/teable

View on GitHub
apps/nextjs-app/src/features/app/blocks/view/kanban/components/KanbanStackHeader.tsx

Summary

Maintainability
A
2 hrs
Test Coverage
import { FieldType } from '@teable/core';
import type { ISelectFieldOptions, ISelectFieldChoice } from '@teable/core';
import { ChevronDown, Minimize2, Pencil, Trash } from '@teable/icons';
import { generateLocalId } from '@teable/sdk/components';
import { useTableId, useViewId } from '@teable/sdk/hooks';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@teable/ui-lib';
import { isEqual } from 'lodash';
import type { Dispatch, SetStateAction } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useClickAway } from 'react-use';
import { ChoiceItem } from '@/features/app/components/field-setting/options/SelectOptions';
import { tableConfig } from '@/features/i18n/table.config';
import type { IKanbanContext } from '../context';
import { useKanban } from '../hooks';
import { useKanbanStackCollapsedStore } from '../store';
import type { IStackData } from '../type';
import { KanbanStackTitle } from './KanbanStackTitle';

interface IKanbanStackHeaderProps {
  stack: IStackData;
  isUncategorized?: boolean;
  setEditMode: Dispatch<SetStateAction<boolean>>;
}

export const KanbanStackHeader = (props: IKanbanStackHeaderProps) => {
  const { stack, isUncategorized, setEditMode } = props;

  const tableId = useTableId();
  const viewId = useViewId();
  const { collapsedStackMap, setCollapsedStackMap } = useKanbanStackCollapsedStore();
  const { permission, stackField } = useKanban() as Required<IKanbanContext>;
  const { t } = useTranslation(tableConfig.i18nNamespaces);

  const { type, options, isLookup } = stackField;
  const { id: stackId, data: stackData } = stack;
  const { stackEditable, stackDeletable } = permission;
  const isSingleSelectField = type === FieldType.SingleSelect && !isLookup;
  const choices = (options as ISelectFieldOptions)?.choices ?? [];

  const choiceRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement | null>();
  const [renamingChoice, setRenamingChoice] = useState<ISelectFieldChoice | null>();

  const onStackRename = () => {
    if (!stackEditable) return;

    const curChoice = choices.find((choice) => choice.name === stackData);

    if (curChoice == null) return;

    setEditMode(true);
    setRenamingChoice({ ...curChoice });
  };

  const onChange = (key: keyof ISelectFieldChoice, value: string) => {
    setRenamingChoice({
      ...(renamingChoice as ISelectFieldChoice),
      [key]: value,
    });
  };

  const onOptionUpdate = () => {
    const value = inputRef.current?.value;
    const newChoice = { ...renamingChoice, name: value };
    if (!value || isEqual(value, stackData)) return;
    const newChoices = choices.map((choice) => {
      if (choice.name === stackData) return newChoice;
      return choice;
    });
    stackField.convert({
      type,
      options: { ...options, choices: newChoices },
    });
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') {
      onOptionUpdate();
      setRenamingChoice(null);
    }
  };

  const onStackDelete = () => {
    const choices = (options as ISelectFieldOptions)?.choices ?? [];
    const newChoices = choices.filter((choice) => choice.name !== stackData);
    stackField.convert({
      type,
      options: { ...options, choices: newChoices },
    });
  };

  const onStackCollapsed = () => {
    const localId = generateLocalId(tableId, viewId);
    const collapsedStackIdSet = new Set(collapsedStackMap[localId] ?? []);
    collapsedStackIdSet.add(stackId);
    setCollapsedStackMap(localId, [...collapsedStackIdSet]);
  };

  useClickAway(choiceRef, () => {
    if (isSingleSelectField) {
      onOptionUpdate();
      setEditMode(false);
      setRenamingChoice(null);
    }
  });

  return (
    <div className="flex h-12 w-full shrink-0 items-center justify-between rounded-t-md border-b bg-background px-4">
      {renamingChoice ? (
        <div ref={choiceRef}>
          <ChoiceItem
            choice={renamingChoice}
            onChange={onChange}
            onKeyDown={onKeyDown}
            onInputRef={(el) => (inputRef.current = el)}
          />
        </div>
      ) : (
        <KanbanStackTitle
          stack={stack}
          isUncategorized={isUncategorized}
          onClick={() => {
            if (!isSingleSelectField) return;
            onStackRename();
            setTimeout(() => inputRef.current?.focus());
          }}
        />
      )}
      <DropdownMenu>
        <DropdownMenuTrigger>
          <ChevronDown className="size-5" />
        </DropdownMenuTrigger>
        <DropdownMenuContent
          className="w-52"
          onCloseAutoFocus={(e) => {
            if (isSingleSelectField) {
              e.preventDefault();
              inputRef.current?.focus();
            }
          }}
        >
          <DropdownMenuItem className="cursor-pointer" onClick={onStackCollapsed}>
            <Minimize2 className="mr-2 size-4" />
            {t('table:kanban.stackMenu.collapseStack')}
          </DropdownMenuItem>
          {isSingleSelectField && !isUncategorized && (
            <>
              {stackEditable && (
                <DropdownMenuItem className="cursor-pointer" onClick={onStackRename}>
                  <Pencil className="mr-2 size-4" />
                  {t('table:kanban.stackMenu.renameStack')}
                </DropdownMenuItem>
              )}
              {stackDeletable && (
                <>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem
                    className="cursor-pointer text-destructive focus:text-destructive"
                    onClick={onStackDelete}
                  >
                    <Trash className="mr-2 size-4" />
                    {t('table:kanban.stackMenu.deleteStack')}
                  </DropdownMenuItem>
                </>
              )}
            </>
          )}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
};