diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts index 2ceebefbd..df69eb723 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts @@ -670,22 +670,29 @@ export class FieldSupplementService { }; } - private prepareSelectOptions(options: ISelectFieldOptionsRo) { + private prepareSelectOptions(options: ISelectFieldOptionsRo, isMultiple: boolean) { const optionsRo = (options ?? SelectFieldCore.defaultOptions()) as ISelectFieldOptionsRo; const nameSet = new Set(); + const choices = optionsRo.choices.map((choice) => { + if (nameSet.has(choice.name)) { + throw new BadRequestException(`choice name ${choice.name} is duplicated`); + } + nameSet.add(choice.name); + return { + name: choice.name, + id: choice.id ?? generateChoiceId(), + color: choice.color ?? ColorUtils.randomColor()[0], + }; + }); + + const defaultValue = optionsRo.defaultValue + ? [optionsRo.defaultValue].flat().filter((name) => nameSet.has(name)) + : undefined; + return { ...optionsRo, - choices: optionsRo.choices.map((choice) => { - if (nameSet.has(choice.name)) { - throw new BadRequestException(`choice name ${choice.name} is duplicated`); - } - nameSet.add(choice.name); - return { - name: choice.name, - id: choice.id ?? generateChoiceId(), - color: choice.color ?? ColorUtils.randomColor()[0], - }; - }), + defaultValue: isMultiple ? defaultValue : defaultValue?.[0], + choices, }; } @@ -695,7 +702,7 @@ export class FieldSupplementService { return { ...field, name: name ?? 'Select', - options: this.prepareSelectOptions(options as ISelectFieldOptionsRo), + options: this.prepareSelectOptions(options as ISelectFieldOptionsRo, false), cellValueType: CellValueType.String, dbFieldType: DbFieldType.Text, }; @@ -707,7 +714,7 @@ export class FieldSupplementService { return { ...field, name: name ?? 'Tags', - options: this.prepareSelectOptions(options as ISelectFieldOptionsRo), + options: this.prepareSelectOptions(options as ISelectFieldOptionsRo, true), cellValueType: CellValueType.String, dbFieldType: DbFieldType.Json, isMultipleCellValue: true, @@ -728,19 +735,27 @@ export class FieldSupplementService { } private async prepareUpdateUserField(fieldRo: IFieldRo, oldFieldVo: IFieldVo) { - const mergeObj = merge({}, oldFieldVo, fieldRo); + const mergeObj = { + ...oldFieldVo, + ...fieldRo, + }; return this.prepareUserField(mergeObj); } private prepareUserField(field: IFieldRo) { - const { name, options = UserFieldCore.defaultOptions() } = field; - const { isMultiple } = options as IUserFieldOptions; + const { name } = field; + const options: IUserFieldOptions = field.options || UserFieldCore.defaultOptions(); + const { isMultiple } = options; + const defaultValue = options.defaultValue ? [options.defaultValue].flat() : undefined; return { ...field, name: name ?? `Collaborator${isMultiple ? 's' : ''}`, - options: options, + options: { + ...options, + defaultValue: isMultiple ? defaultValue : defaultValue?.[0], + }, cellValueType: CellValueType.String, dbFieldType: DbFieldType.Json, isMultipleCellValue: isMultiple || undefined, diff --git a/apps/nestjs-backend/src/features/record/record-calculate/record-calculate.service.ts b/apps/nestjs-backend/src/features/record/record-calculate/record-calculate.service.ts index 82b0cc68c..83d128f54 100644 --- a/apps/nestjs-backend/src/features/record/record-calculate/record-calculate.service.ts +++ b/apps/nestjs-backend/src/features/record/record-calculate/record-calculate.service.ts @@ -1,9 +1,11 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import type { IMakeOptional } from '@teable/core'; +import type { IMakeOptional, IUserFieldOptions } from '@teable/core'; import { FieldKeyType, generateRecordId, FieldType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ICreateRecordsRo, ICreateRecordsVo, IRecord } from '@teable/openapi'; -import { isEmpty, keyBy } from 'lodash'; +import { isEmpty, keyBy, uniq } from 'lodash'; +import { ClsService } from 'nestjs-cls'; +import type { IClsStore } from '../../../types/cls'; import { BatchService } from '../../calculation/batch.service'; import { FieldCalculationService } from '../../calculation/field-calculation.service'; import { LinkService } from '../../calculation/link.service'; @@ -24,7 +26,8 @@ export class RecordCalculateService { private readonly recordService: RecordService, private readonly linkService: LinkService, private readonly referenceService: ReferenceService, - private readonly fieldCalculationService: FieldCalculationService + private readonly fieldCalculationService: FieldCalculationService, + private readonly clsService: ClsService ) {} async multipleCreateRecords( @@ -201,16 +204,17 @@ export class RecordCalculateService { fieldKeyType: FieldKeyType, fieldRaws: IFieldRaws ) { - return records.map((record) => { + const processedRecords = records.map((record) => { const fields: { [fieldIdOrName: string]: unknown } = { ...record.fields }; for (const fieldRaw of fieldRaws) { - const { type, options } = fieldRaw; - if (options == null) continue; - const { defaultValue } = JSON.parse(options) || {}; + const { type, options, isComputed } = fieldRaw; + if (options == null || isComputed) continue; + const optionsObj = JSON.parse(options) || {}; + const { defaultValue } = optionsObj; if (defaultValue == null) continue; const fieldIdOrName = fieldRaw[fieldKeyType]; if (fields[fieldIdOrName] != null) continue; - fields[fieldIdOrName] = this.getDefaultValue(type as FieldType, defaultValue); + fields[fieldIdOrName] = this.getDefaultValue(type as FieldType, optionsObj, defaultValue); } return { @@ -218,13 +222,112 @@ export class RecordCalculateService { fields, }; }); + + // After process to handle user field + const userFields = fieldRaws.filter((fieldRaw) => fieldRaw.type === FieldType.User); + if (userFields.length > 0) { + return await this.fillUserInfo(processedRecords, userFields, fieldKeyType); + } + + return processedRecords; + } + + private async fillUserInfo( + records: { id: string; fields: { [fieldNameOrId: string]: unknown } }[], + userFields: IFieldRaws, + fieldKeyType: FieldKeyType + ) { + const userIds = new Set(); + records.forEach((record) => { + userFields.forEach((field) => { + const fieldIdOrName = field[fieldKeyType]; + const value = record.fields[fieldIdOrName]; + if (value) { + if (Array.isArray(value)) { + value.forEach((v) => userIds.add(v.id)); + } else { + userIds.add((value as { id: string }).id); + } + } + }); + }); + + const userInfo = await this.getUserInfoFromDatabase(Array.from(userIds)); + + return records.map((record) => { + const updatedFields = { ...record.fields }; + userFields.forEach((field) => { + const fieldIdOrName = field[fieldKeyType]; + const value = updatedFields[fieldIdOrName]; + if (value) { + if (Array.isArray(value)) { + updatedFields[fieldIdOrName] = value.map((v) => ({ + ...v, + ...userInfo[v.id], + })); + } else { + updatedFields[fieldIdOrName] = { + ...value, + ...userInfo[(value as { id: string }).id], + }; + } + } + }); + return { + ...record, + fields: updatedFields, + }; + }); + } + + private async getUserInfoFromDatabase( + userIds: string[] + ): Promise<{ [id: string]: { id: string; title: string; email: string } }> { + const usersRaw = await this.prismaService.txClient().user.findMany({ + where: { + id: { in: userIds }, + deletedTime: null, + }, + select: { + id: true, + name: true, + email: true, + }, + }); + return keyBy( + usersRaw.map((user) => ({ id: user.id, title: user.name, email: user.email })), + 'id' + ); + } + + private transformUserDefaultValue(options: IUserFieldOptions, defaultValue: string | string[]) { + const currentUserId = this.clsService.get('user.id'); + const defaultIds = uniq([defaultValue].flat().map((id) => (id === 'me' ? currentUserId : id))); + + if (options.isMultiple) { + return defaultIds.map((id) => ({ id })); + } + return defaultIds[0] ? { id: defaultIds[0] } : undefined; } - private getDefaultValue(type: FieldType, defaultValue: unknown) { - if (type === FieldType.Date && defaultValue === 'now') { - return new Date().toISOString(); + private getDefaultValue(type: FieldType, options: unknown, defaultValue: unknown) { + switch (type) { + case FieldType.Date: + return defaultValue === 'now' ? new Date().toISOString() : defaultValue; + case FieldType.SingleSelect: + return Array.isArray(defaultValue) ? defaultValue[0] : defaultValue; + case FieldType.MultipleSelect: + return Array.isArray(defaultValue) ? defaultValue : [defaultValue]; + case FieldType.User: + return this.transformUserDefaultValue( + options as IUserFieldOptions, + defaultValue as string | string[] + ); + case FieldType.Checkbox: + return defaultValue ? true : null; + default: + return defaultValue; } - return defaultValue; } async createRecords( @@ -253,6 +356,7 @@ export class RecordCalculateService { options: true, unique: true, notNull: true, + isComputed: true, isLookup: true, dbFieldName: true, }, diff --git a/apps/nestjs-backend/src/features/record/type.ts b/apps/nestjs-backend/src/features/record/type.ts index 78313240f..af8ec7750 100644 --- a/apps/nestjs-backend/src/features/record/type.ts +++ b/apps/nestjs-backend/src/features/record/type.ts @@ -2,5 +2,13 @@ import type { Field } from '@prisma/client'; export type IFieldRaws = Pick< Field, - 'id' | 'name' | 'type' | 'options' | 'unique' | 'notNull' | 'isLookup' | 'dbFieldName' + | 'id' + | 'name' + | 'type' + | 'options' + | 'unique' + | 'notNull' + | 'isComputed' + | 'isLookup' + | 'dbFieldName' >[]; diff --git a/apps/nestjs-backend/test/record.e2e-spec.ts b/apps/nestjs-backend/test/record.e2e-spec.ts index 72b37ea45..eae422169 100644 --- a/apps/nestjs-backend/test/record.e2e-spec.ts +++ b/apps/nestjs-backend/test/record.e2e-spec.ts @@ -24,6 +24,7 @@ describe('OpenAPI RecordController (e2e)', () => { let app: INestApplication; const baseId = globalThis.testConfig.baseId; + const userId = globalThis.testConfig.userId; beforeAll(async () => { const appCtx = await initApp(); @@ -561,4 +562,130 @@ describe('OpenAPI RecordController (e2e)', () => { expect(data.records[0].fields[rollup.id]).toBeUndefined(); }); }); + + describe('create record with default value', () => { + let table: ITableFullVo; + beforeAll(async () => { + table = await createTable(baseId, { + name: 'table1', + }); + }); + + afterAll(async () => { + await permanentDeleteTable(baseId, table.id); + }); + + it('should create a record with default single select', async () => { + const field = await createField(table.id, { + type: FieldType.SingleSelect, + options: { + choices: [{ name: 'default value' }], + defaultValue: 'default value', + }, + }); + + const { records } = await createRecords(table.id, { + records: [ + { + fields: {}, + }, + ], + }); + + expect(records[0].fields[field.id]).toEqual('default value'); + }); + + it('should create a record with default multiple select', async () => { + const field = await createField(table.id, { + type: FieldType.MultipleSelect, + options: { + choices: [{ name: 'default value' }, { name: 'default value2' }], + defaultValue: ['default value', 'default value2'], + }, + }); + + const { records } = await createRecords(table.id, { + records: [ + { + fields: {}, + }, + ], + }); + + expect(records[0].fields[field.id]).toEqual(['default value', 'default value2']); + }); + + it('should create a record with default number', async () => { + const field = await createField(table.id, { + type: FieldType.Number, + options: { + defaultValue: 1, + }, + }); + + const { records } = await createRecords(table.id, { + records: [ + { + fields: {}, + }, + ], + }); + + expect(records[0].fields[field.id]).toEqual(1); + }); + + it('should create a record with default user', async () => { + const field = await createField(table.id, { + type: FieldType.User, + options: { + defaultValue: userId, + }, + }); + const field2 = await createField(table.id, { + type: FieldType.User, + options: { + isMultiple: true, + defaultValue: ['me'], + }, + }); + const field3 = await createField(table.id, { + type: FieldType.User, + options: { + isMultiple: true, + defaultValue: [userId], + }, + }); + + const { records } = await createRecords(table.id, { + records: [ + { + fields: {}, + }, + ], + }); + + expect(records[0].fields[field.id]).toMatchObject({ + id: userId, + title: expect.any(String), + email: expect.any(String), + avatarUrl: expect.any(String), + }); + expect(records[0].fields[field2.id]).toMatchObject([ + { + id: userId, + title: expect.any(String), + email: expect.any(String), + avatarUrl: expect.any(String), + }, + ]); + expect(records[0].fields[field3.id]).toMatchObject([ + { + id: userId, + title: expect.any(String), + email: expect.any(String), + avatarUrl: expect.any(String), + }, + ]); + }); + }); }); diff --git a/apps/nextjs-app/src/features/app/components/field-setting/DefaultValue.tsx b/apps/nextjs-app/src/features/app/components/field-setting/DefaultValue.tsx new file mode 100644 index 000000000..f235e8d17 --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/DefaultValue.tsx @@ -0,0 +1,33 @@ +import { Label } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import React from 'react'; +import { tableConfig } from '@/features/i18n/table.config'; + +export const DefaultValue = (props: { children: React.ReactNode; onReset?: () => void }) => { + const { children, onReset } = props; + const { t } = useTranslation(tableConfig.i18nNamespaces); + + return ( +
+ + {children} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx index 6d375d597..e5dfa835f 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldOptions.tsx @@ -11,16 +11,20 @@ import type { ICreatedTimeFieldOptions, ILastModifiedTimeFieldOptions, IUserFieldOptions, + ICheckboxFieldOptions, + ILongTextFieldOptions, } from '@teable/core'; import { FieldType } from '@teable/core'; +import { CheckboxOptions } from './options/CheckboxOptions'; import { CreatedTimeOptions } from './options/CreatedTimeOptions'; import { DateOptions } from './options/DateOptions'; import { FormulaOptions } from './options/FormulaOptions'; import { LinkOptions } from './options/LinkOptions'; +import { LongTextOptions } from './options/LongTextOptions'; import { NumberOptions } from './options/NumberOptions'; import { RatingOptions } from './options/RatingOptions'; import { RollupOptions } from './options/RollupOptions'; -import { SelectOptions } from './options/SelectOptions'; +import { SelectOptions } from './options/SelectOptions/SelectOptions'; import { SingleLineTextOptions } from './options/SingleLineTextOptions'; import { UserOptions } from './options/UserOptions'; import type { IFieldEditorRo } from './type'; @@ -36,14 +40,24 @@ export const FieldOptions: React.FC = ({ field, onChange }) case FieldType.SingleLineText: return ( ); + case FieldType.LongText: + return ( + + ); case FieldType.SingleSelect: case FieldType.MultipleSelect: return ( = ({ field, onChange }) onChange={onChange} /> ); + case FieldType.Checkbox: + return ( + + ); case FieldType.Rollup: return ( | undefined; + onChange?: (options: Partial) => void; + isLookup?: boolean; +}) => { + const { isLookup, options, onChange } = props; + const onDefaultValueChange = (defaultValue: boolean | undefined) => { + onChange?.({ + defaultValue: defaultValue || undefined, + }); + }; + + return ( +
+ {!isLookup && ( + + onDefaultValueChange(checked)} + /> + + )} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/options/LongTextOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/options/LongTextOptions.tsx new file mode 100644 index 000000000..e1b11c39c --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/options/LongTextOptions.tsx @@ -0,0 +1,32 @@ +import type { ILongTextFieldOptions } from '@teable/core'; +import { Textarea } from '@teable/ui-lib/shadcn'; +import { DefaultValue } from '../DefaultValue'; + +export const LongTextOptions = (props: { + options: Partial | undefined; + onChange?: (options: Partial) => void; + isLookup?: boolean; +}) => { + const { isLookup, options, onChange } = props; + + const onDefaultValueChange = (defaultValue: string | undefined) => { + onChange?.({ + defaultValue, + }); + }; + + return ( +
+ {!isLookup && ( + onDefaultValueChange(undefined)}> +