teableio/teable

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

Summary

Maintainability
A
0 mins
Test Coverage
import { useQueryClient } from '@tanstack/react-query';
import type { IFieldRo } from '@teable/core';
import { convertFieldRoSchema, FieldType, getOptionsSchema } from '@teable/core';
import { Share2 } from '@teable/icons';
import { planFieldCreate, type IPlanFieldConvertVo, planFieldConvert } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useTable, useView } from '@teable/sdk/hooks';
import { ConfirmDialog } from '@teable/ui-lib/base';
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogFooter,
  DialogTrigger,
} from '@teable/ui-lib/shadcn';
import { Button } from '@teable/ui-lib/shadcn/ui/button';
import { Sheet, SheetContent } from '@teable/ui-lib/shadcn/ui/sheet';
import { toast } from '@teable/ui-lib/shadcn/ui/sonner';
import { useTranslation } from 'next-i18next';
import { useCallback, useMemo, useState } from 'react';
import { fromZodError } from 'zod-validation-error';
import { tableConfig } from '@/features/i18n/table.config';
import { DynamicFieldGraph } from '../../blocks/graph/DynamicFieldGraph';
import { ProgressBar } from '../../blocks/graph/ProgressBar';
import { DynamicFieldEditor } from './DynamicFieldEditor';
import { useDefaultFieldName } from './hooks/useDefaultFieldName';
import type { IFieldEditorRo, IFieldSetting, IFieldSettingBase } from './type';
import { FieldOperator } from './type';

export const FieldSetting = (props: IFieldSetting) => {
  const { operator, order } = props;

  const table = useTable();
  const view = useView();
  const getDefaultFieldName = useDefaultFieldName();

  const [graphVisible, setGraphVisible] = useState<boolean>(false);
  const [processVisible, setProcessVisible] = useState<boolean>(false);
  const [plan, setPlan] = useState<IPlanFieldConvertVo>();
  const [fieldRo, setFieldRo] = useState<IFieldRo>();
  const queryClient = useQueryClient();
  const { t } = useTranslation(tableConfig.i18nNamespaces);

  const onCancel = () => {
    props.onCancel?.();
  };

  const createNewField = async (field: IFieldRo) => {
    const fieldName = field.name ?? (await getDefaultFieldName(field));
    return await table?.createField({ ...field, name: fieldName });
  };

  const performAction = async (field: IFieldRo) => {
    setGraphVisible(false);
    if (plan && (plan.estimateTime || 0) > 1000) {
      setProcessVisible(true);
    }
    try {
      if (operator === FieldOperator.Add) {
        await createNewField(field);
      }

      if (operator === FieldOperator.Insert) {
        await createNewField({
          ...field,
          order:
            view && order != null
              ? {
                  viewId: view.id,
                  orderIndex: order,
                }
              : undefined,
        });
      }

      if (operator === FieldOperator.Edit) {
        const fieldId = props.field?.id;
        table && fieldId && (await table.convertField(fieldId, field));
      }

      toast(
        operator === FieldOperator.Edit
          ? t('table:field.editor.fieldUpdated')
          : t('table:field.editor.fieldCreated')
      );
    } finally {
      setProcessVisible(false);
    }

    props.onConfirm?.();
  };

  const getPlan = async (fieldRo: IFieldRo) => {
    if (operator === FieldOperator.Edit) {
      return queryClient.ensureQueryData({
        queryKey: ReactQueryKeys.planFieldConvert(
          table?.id as string,
          props.field?.id as string,
          fieldRo
        ),
        queryFn: ({ queryKey }) =>
          planFieldConvert(queryKey[1], queryKey[2], queryKey[3]).then((data) => data.data),
      });
    }
    return queryClient.ensureQueryData({
      queryKey: ReactQueryKeys.planFieldCreate(table?.id as string, fieldRo),
      queryFn: ({ queryKey }) => planFieldCreate(queryKey[1], queryKey[2]),
    });
  };

  const onConfirm = async (fieldRo?: IFieldRo) => {
    if (!fieldRo) {
      return onCancel();
    }

    const plan = (await getPlan(fieldRo)) as IPlanFieldConvertVo;
    setFieldRo(fieldRo);
    setPlan(plan);
    if (plan && (plan.estimateTime || 0) > 1000) {
      setGraphVisible(true);
      return;
    }

    await performAction(fieldRo);
  };

  return (
    <>
      <FieldSettingBase {...props} onCancel={onCancel} onConfirm={onConfirm} />
      <ConfirmDialog
        contentClassName="max-w-4xl"
        title={t('table:field.editor.previewDependenciesGraph')}
        open={graphVisible}
        onOpenChange={setGraphVisible}
        content={
          <>
            <DynamicFieldGraph
              tableId={table?.id as string}
              fieldId={props.field?.id}
              fieldRo={fieldRo}
            />
            <p className="text-sm">{t('table:field.editor.areYouSurePerformIt')}</p>
          </>
        }
        cancelText={t('common:actions.cancel')}
        confirmText={t('common:actions.confirm')}
        onCancel={() => setGraphVisible(false)}
        onConfirm={() => performAction(fieldRo as IFieldRo)}
      />
      <ConfirmDialog
        open={processVisible}
        onOpenChange={setProcessVisible}
        title={t('table:field.editor.calculating')}
        content={
          <ProgressBar duration={plan?.estimateTime || 0} cellCount={plan?.updateCellCount || 0} />
        }
      />
    </>
  );
};

const FieldSettingBase = (props: IFieldSettingBase) => {
  const { visible, field: originField, operator, onConfirm, onCancel } = props;
  const { t } = useTranslation(tableConfig.i18nNamespaces);
  const table = useTable();
  const [field, setField] = useState<IFieldEditorRo>(
    originField
      ? { ...originField, options: getOptionsSchema(originField.type).parse(originField.options) }
      : {
          type: FieldType.SingleLineText,
        }
  );
  const [alertVisible, setAlertVisible] = useState<boolean>(false);
  const [updateCount, setUpdateCount] = useState<number>(0);
  const [showGraphButton, setShowGraphButton] = useState<boolean>(operator === FieldOperator.Edit);

  const isCreatingSimpleField = useCallback(
    (field: IFieldEditorRo) => {
      return (
        !field.lookupOptions &&
        field.type !== FieldType.Link &&
        field.type !== FieldType.Formula &&
        operator !== FieldOperator.Edit
      );
    },
    [operator]
  );

  const checkFieldReady = useCallback(
    (field: IFieldEditorRo) => {
      const result = convertFieldRoSchema.safeParse(field);
      if (!result.success) {
        return false;
      }
      const data = result.data;
      if (isCreatingSimpleField(data)) {
        return false;
      }
      return true;
    },
    [isCreatingSimpleField]
  );

  const onOpenChange = (open?: boolean) => {
    if (open) {
      return;
    }
    onCancelInner();
  };

  const onFieldEditorChange = useCallback(
    (field: IFieldEditorRo) => {
      setField(field);
      setUpdateCount(1);
      setShowGraphButton(checkFieldReady(field));
    },
    [checkFieldReady]
  );

  const onCancelInner = () => {
    if (updateCount > 0) {
      setAlertVisible(true);
      return;
    }
    onCancel?.();
  };

  const onSave = () => {
    !updateCount && onConfirm?.();
    const result = convertFieldRoSchema.safeParse(field);
    if (result.success) {
      onConfirm?.(result.data);
      return;
    }
    console.error('fieldConFirm', field);
    console.error('fieldConFirmResult', fromZodError(result.error).message);
    toast.error(`Options Error`, {
      description: fromZodError(result.error).message,
    });
  };

  const title = useMemo(() => {
    switch (operator) {
      case FieldOperator.Add:
        return t('table:field.editor.addField');
      case FieldOperator.Edit:
        return t('table:field.editor.editField');
      case FieldOperator.Insert:
        return t('table:field.editor.insertField');
    }
  }, [operator, t]);

  return (
    <>
      <Sheet open={visible} onOpenChange={onOpenChange}>
        <SheetContent className="w-[328px] p-2" side="right">
          <div className="flex h-full flex-col gap-2">
            {/* Header */}
            <div className="text-md mx-2 w-full border-b py-2 font-semibold">{title}</div>
            {/* Content Form */}
            {
              <DynamicFieldEditor
                isPrimary={originField?.isPrimary}
                field={field}
                operator={operator}
                onChange={onFieldEditorChange}
              />
            }
            {/* Footer */}
            <div className="flex w-full shrink-0 justify-between p-2">
              <div>
                {showGraphButton && (
                  <Dialog>
                    <DialogTrigger asChild>
                      <Button size={'sm'} variant={'ghost'}>
                        <Share2 className="size-4" /> {t('table:field.editor.graph')}
                      </Button>
                    </DialogTrigger>
                    <DialogContent className="max-w-4xl">
                      <DynamicFieldGraph
                        tableId={table?.id as string}
                        fieldId={props.field?.id}
                        fieldRo={updateCount ? (field as IFieldRo) : undefined}
                      />
                      <DialogFooter>
                        <DialogClose asChild>
                          <Button type="button" variant="secondary">
                            {t('common:actions.close')}
                          </Button>
                        </DialogClose>
                      </DialogFooter>
                    </DialogContent>
                  </Dialog>
                )}
              </div>
              <div className="flex gap-2">
                <Button size={'sm'} variant={'ghost'} onClick={onCancel}>
                  {t('common:actions.cancel')}
                </Button>
                <Button size={'sm'} onClick={onSave}>
                  {t('common:actions.save')}
                </Button>
              </div>
            </div>
          </div>
        </SheetContent>
      </Sheet>
      <ConfirmDialog
        open={alertVisible}
        closeable={true}
        onOpenChange={setAlertVisible}
        title={t('table:field.editor.doSaveChanges')}
        onCancel={onCancel}
        cancelText={t('common:actions.doNotSave')}
        confirmText={t('common:actions.save')}
        onConfirm={onSave}
      />
    </>
  );
};