apps/nextjs-app/src/features/app/components/field-setting/options/FormulaOptions.tsx
import type { IFormulaFieldOptions, IUnionFormatting, IUnionShowAs } from '@teable/core';
import {
CellValueType,
getShowAsSchema,
getFormattingSchema,
getDefaultFormatting,
} from '@teable/core';
import { FormulaEditor } from '@teable/sdk/components';
import { useFields } from '@teable/sdk/hooks';
import type { IFieldInstance } from '@teable/sdk/model';
import { FormulaField } from '@teable/sdk/model';
import { Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';
import { isEmpty, isEqual, keyBy } from 'lodash';
import { useTranslation } from 'next-i18next';
import { useCallback, useMemo, useState } from 'react';
import { TimeZoneFormatting } from '../formatting/TimeZoneFormatting';
import { UnionFormatting } from '../formatting/UnionFormatting';
import { UnionShowAs } from '../show-as/UnionShowAs';
const calculateTypedValue = (fields: IFieldInstance[], expression?: string) => {
const defaultResult = { cellValueType: CellValueType.String, isMultipleCellValue: false };
try {
return expression
? FormulaField.getParsedValueType(expression, keyBy(fields, 'id'))
: defaultResult;
} catch (e) {
return defaultResult;
}
};
export const FormulaOptionsInner = (props: {
options: Partial<IFormulaFieldOptions> | undefined;
onChange?: (options: Partial<IFormulaFieldOptions>) => void;
}) => {
const { options = {}, onChange } = props;
const { expression, formatting, showAs } = options;
const fields = useFields({ withHidden: true, withDenied: true });
const [visible, setVisible] = useState(false);
const { t } = useTranslation(['table']);
const expressionByName = useMemo(() => {
return expression
? FormulaField.convertExpressionIdToName(expression, keyBy(fields, 'id'))
: '';
}, [expression, fields]);
const { cellValueType, isMultipleCellValue } = calculateTypedValue(fields, expression);
const onExpressionChange = (expr: string) => {
const { cellValueType: newCellValueType } = calculateTypedValue(fields, expr);
const newOptions: IFormulaFieldOptions = {
expression: expr,
timeZone:
formatting && 'timeZone' in formatting && formatting?.timeZone
? formatting.timeZone
: options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
};
if (newCellValueType !== cellValueType) {
const defaultFormatting = getDefaultFormatting(newCellValueType);
newOptions.formatting = defaultFormatting;
newOptions.showAs = undefined;
}
onChange?.(newOptions);
setVisible(false);
};
const setFormatting = useCallback(
(newFormatting: IUnionFormatting) => {
const formattingResult = getFormattingSchema(cellValueType).safeParse(newFormatting);
const formattingParsed = formattingResult.success ? formattingResult.data : undefined;
if (isEqual(formattingParsed, formatting)) {
return;
}
onChange?.({
formatting: isEmpty(formattingParsed) ? undefined : newFormatting,
timeZone: options.timeZone,
});
},
[cellValueType, formatting, onChange, options.timeZone]
);
const setTimeZone = useCallback(
(newTimeZone: string) => {
if (newTimeZone === options.timeZone) {
return;
}
onChange?.({ timeZone: newTimeZone });
},
[options.timeZone, onChange]
);
const setShowAs = useCallback(
(newShowAs?: IUnionShowAs) => {
const showAsResult = getShowAsSchema(cellValueType, isMultipleCellValue).safeParse(newShowAs);
const showAsParsed = showAsResult.success ? showAsResult.data : undefined;
if (isEqual(showAsParsed, showAs)) {
return;
}
onChange?.({ showAs: isEmpty(showAsParsed) ? undefined : newShowAs });
},
[cellValueType, isMultipleCellValue, onChange, showAs]
);
return (
<div className="w-full space-y-2">
<div className="space-y-2">
<span className="neutral-content label-text">{t('field.default.formula.formula')}</span>
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger asChild>
<code className="block min-h-[36px] cursor-pointer items-center whitespace-pre-wrap break-words rounded-md border border-input bg-background px-3 py-2 ring-offset-background">
{expressionByName}
</code>
</DialogTrigger>
<DialogContent
tabIndex={-1}
closeable
className="flex size-auto max-w-full overflow-hidden rounded-sm p-0 outline-0 md:w-auto"
>
<FormulaEditor expression={expression} onConfirm={onExpressionChange} />
</DialogContent>
</Dialog>
</div>
<div className="space-y-2">
<UnionFormatting
cellValueType={cellValueType}
formatting={formatting}
onChange={setFormatting}
/>
{cellValueType !== CellValueType.DateTime && (
<TimeZoneFormatting
timeZone={options?.timeZone}
onChange={(value) => setTimeZone(value)}
/>
)}
</div>
{Boolean(expression) && (
<div className="space-y-2">
<UnionShowAs
showAs={showAs}
cellValueType={cellValueType}
isMultipleCellValue={isMultipleCellValue}
onChange={setShowAs}
/>
</div>
)}
</div>
);
};
export const FormulaOptions = (props: {
options: Partial<IFormulaFieldOptions> | undefined;
isLookup?: boolean;
cellValueType?: CellValueType;
isMultipleCellValue?: boolean;
onChange?: (options: Partial<IFormulaFieldOptions>) => void;
}) => {
const {
options,
isLookup,
cellValueType = CellValueType.String,
isMultipleCellValue,
onChange,
} = props;
const { expression, formatting, showAs } = options || {};
if (isLookup) {
return (
<div className="w-full space-y-2">
<div className="space-y-2">
<UnionFormatting
cellValueType={cellValueType}
formatting={formatting}
onChange={(formatting) => onChange?.({ formatting })}
/>
</div>
{Boolean(expression) && (
<div className="space-y-2">
<UnionShowAs
showAs={showAs}
cellValueType={cellValueType}
isMultipleCellValue={isMultipleCellValue}
onChange={(showAs) => onChange?.({ showAs })}
/>
</div>
)}
</div>
);
}
return <FormulaOptionsInner options={options} onChange={onChange} />;
};