Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ViewOpBuilder,
FieldOpBuilder,
getValidStatisticFunc,
ViewType,
} from '@teable/core';
import type {
IFilterSet,
Expand Down Expand Up @@ -259,6 +260,28 @@ export class FieldViewSyncService {
opsMap[viewId] = [...(opsMap[viewId] || []), updateOp];
}
}

// For Form views: enforce visibility when field is not null and no default value
if (view.type === ViewType.Form) {
const defaultValue = (newField.options as { defaultValue?: string })?.defaultValue;
const protectedNew = Boolean(newField.notNull) && !defaultValue;
const defaultValueOld = (
oldField.options as {
defaultValue?: string;
}
)?.defaultValue;
const protectedOld = Boolean(oldField.notNull) && !defaultValueOld;

if (protectedNew && !protectedOld) {
const prev = columnMeta[fieldId] ?? {};
const updateOp = ViewOpBuilder.editor.updateViewColumnMeta.build({
fieldId,
newColumnMeta: { ...prev, visible: true } as IColumn,
oldColumnMeta: prev as IColumn,
});
opsMap[viewId] = [...(opsMap[viewId] || []), updateOp];
}
}
}
}

Expand Down
27 changes: 27 additions & 0 deletions apps/nestjs-backend/src/features/view/view.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ export class ViewService implements IReadonlyAdapterService {
return this.createViewIndexField(dbTableName, viewId);
}

// eslint-disable-next-line sonarjs/cognitive-complexity
private async viewDataCompensation(tableId: string, viewRo: IViewRo) {
// create view compensation data
const innerViewRo = { ...viewRo };

// primary field set visible default
if ([ViewType.Kanban, ViewType.Gallery, ViewType.Calendar].includes(viewRo.type)) {
const primaryField = await this.prismaService.txClient().field.findFirstOrThrow({
Expand Down Expand Up @@ -195,6 +197,31 @@ export class ViewService implements IReadonlyAdapterService {
};
}
}

if (viewRo.type === ViewType.Form) {
const fields = await this.prismaService.txClient().field.findMany({
where: { tableId, deletedTime: null },
select: {
id: true,
type: true,
isComputed: true,
},
orderBy: [{ order: 'asc' }, { createdTime: 'asc' }],
});

if (!fields?.length) return innerViewRo;

const columnMeta = innerViewRo.columnMeta ?? {};
for (const f of fields) {
const { id, type, isComputed } = f;

if (isComputed || type === FieldType.Button) continue;

const prev = columnMeta[id] ?? {};
columnMeta[id] = { ...prev, visible: true } as IColumn;
}
innerViewRo.columnMeta = columnMeta;
}
return innerViewRo;
}

Expand Down
36 changes: 35 additions & 1 deletion apps/nestjs-backend/test/field-view-sync.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import type {
IGridColumnMeta,
ISelectFieldChoice,
ISelectFieldOptions,
IFormColumn,
} from '@teable/core';
import { FieldType, ViewType, SortFunc, Colors, StatisticsFunc } from '@teable/core';
import { FieldKeyType, FieldType, ViewType, SortFunc, Colors, StatisticsFunc } from '@teable/core';
import { updateRecords } from '@teable/openapi';
import {
createTable,
createView,
Expand All @@ -15,6 +17,7 @@ import {
getViews,
updateViewColumnMeta,
convertField,
getRecords,
} from './utils/init-app';

describe('OpenAPI FieldController (e2e)', () => {
Expand Down Expand Up @@ -146,6 +149,37 @@ describe('OpenAPI FieldController (e2e)', () => {
expect(formViewAfterDelete?.columnMeta).not.haveOwnProperty(numberField.id);
});

it('should set form column visible after setting field notNull without default', async () => {
const textField = fields.find(({ type }) => type === FieldType.SingleLineText) as IFieldVo;

const formView = await createView(tableId, {
type: ViewType.Form,
name: 'Form',
});

const recordResult = await getRecords(tableId);
await updateRecords(tableId, {
fieldKeyType: FieldKeyType.Id,
records: recordResult.records.map((rec) => ({
id: rec.id,
fields: { [textField.id]: 'filled' },
})),
});

await convertField(tableId, textField.id, {
name: textField.name,
dbFieldName: textField.dbFieldName,
type: textField.type,
options: {},
notNull: true,
});

const views = await getViews(tableId);
const formAfter = views.find(({ id }) => id === formView.id)!;
const formColumnMeta = formAfter.columnMeta as unknown as Record<string, IFormColumn>;
expect(formColumnMeta[textField.id]?.visible ?? false).toBe(true);
});

it('should sync the selected value after update select type field option name', async () => {
const statusField = fields.find(({ type }) => type === FieldType.SingleSelect) as IFieldVo;
const defaultSelectValue = (statusField.options as ISelectFieldOptions)?.choices[0].name;
Expand Down
23 changes: 22 additions & 1 deletion apps/nestjs-backend/test/view.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,25 @@ describe('OpenAPI ViewController (e2e)', () => {
expect(fields.length).toEqual(Object.keys(columnMetaResponse).length);
});

it('should set all eligible fields visible when creating form view', async () => {
const formView = await createView(table.id, {
name: 'Form view',
type: ViewType.Form,
});

const views = await getViews(table.id);
const createdForm = views.find(({ id }) => id === formView.id)!;
const formColumnMeta = createdForm.columnMeta as unknown as Record<string, IFormColumn>;

const eligibleFieldIds = table.fields
.filter((f) => !f.isComputed && !f.isLookup && f.type !== FieldType.Button)
.map((f) => f.id);

eligibleFieldIds.forEach((fieldId) => {
expect(formColumnMeta[fieldId]?.visible ?? false).toBe(true);
});
});

it('should batch update view when create field', async () => {
const initialColumnMeta = await viewService.generateViewOrderColumnMeta(table.id);
const createData: Prisma.ViewCreateManyInput[] = [];
Expand Down Expand Up @@ -566,9 +585,11 @@ describe('OpenAPI ViewController (e2e)', () => {
order: index,
} as unknown as IFormColumnMeta;
if (index === 0) {
(pre[cur.id] as unknown as IFormColumn).visible = false;
(pre[cur.id] as unknown as IFormColumn).required = true;
}
if (!cur.isComputed && cur.type !== FieldType.Button) {
(pre[cur.id] as unknown as IFormColumn).visible = true;
}
return pre;
},
{} as Record<string, IFormColumnMeta>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { IFieldInstance } from '@teable/sdk/model';
import { useEffect, useMemo, useState } from 'react';
import { FieldSetting } from '../../grid/components';
import { FORM_SIDEBAR_DROPPABLE_ID } from '../constant';
import { isProtectedField } from '../util';
import { FormEditorMain } from './FormEditorMain';
import { FormFieldEditor } from './FormFieldEditor';
import { DragItem, FormSidebar } from './FormSidebar';
Expand Down Expand Up @@ -111,8 +112,11 @@ export const FormEditor = () => {
}

if (activeField && overId === FORM_SIDEBAR_DROPPABLE_ID && !additionalFieldData) {
const sourceDragId = activeField.id;
setSidebarAdditionalFieldId(sourceDragId);
const isProtected = isProtectedField(activeField);
if (!isProtected) {
const sourceDragId = activeField.id;
setSidebarAdditionalFieldId(sourceDragId);
}
}
};

Expand Down Expand Up @@ -201,15 +205,18 @@ export const FormEditor = () => {
}

if (activeField && overId === FORM_SIDEBAR_DROPPABLE_ID) {
const sourceDragId = activeField.id;
await view?.updateColumnMeta([
{
fieldId: sourceDragId,
columnMeta: {
visible: false,
const isProtected = isProtectedField(activeField);
if (!isProtected) {
const sourceDragId = activeField.id;
await view?.updateColumnMeta([
{
fieldId: sourceDragId,
columnMeta: {
visible: false,
},
},
},
]);
]);
}
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FormView, IFieldInstance } from '@teable/sdk/model';
import { useTranslation } from 'next-i18next';
import type { FC } from 'react';
import { tableConfig } from '@/features/i18n/table.config';
import { isProtectedField } from '../util';
import { FormCellEditor } from './FormCellEditor';

interface IFormFieldEditorProps {
Expand All @@ -28,7 +29,8 @@ export const FormField: FC<IFormFieldEditorProps> = (props) => {
hasAiConfig: Boolean(aiConfig),
}).Icon;

const required = field.notNull || view?.columnMeta[fieldId]?.required;
const isProtected = isProtectedField(field);
const required = isProtected || view?.columnMeta[fieldId]?.required;
const isError = errors.has(fieldId);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { useTranslation } from 'next-i18next';
import type { FC } from 'react';
import { tableConfig } from '@/features/i18n/table.config';
import { isProtectedField } from '../util';

interface IFormFieldEditorProps {
field: IFieldInstance;
Expand All @@ -29,6 +30,8 @@ export const FormFieldEditor: FC<IFormFieldEditorProps> = (props) => {
if (!view || !tableId) return null;

const { type, name, description, isComputed, isLookup, id: fieldId, aiConfig } = field;
const isProtected = isProtectedField(field);
const required = isProtected || view.columnMeta[fieldId]?.required;
const Icon = getFieldStatic(type, {
isLookup,
isConditionalLookup: field.isConditionalLookup,
Expand Down Expand Up @@ -58,8 +61,6 @@ export const FormFieldEditor: FC<IFormFieldEditorProps> = (props) => {
]);
};

const required = field.notNull || view.columnMeta[fieldId]?.required;

return (
<div className="relative w-full px-8 py-5">
<div className="mb-2 flex w-full items-center justify-between">
Expand All @@ -73,28 +74,50 @@ export const FormFieldEditor: FC<IFormFieldEditorProps> = (props) => {
{!isComputed && (
<div className="flex shrink-0 items-center" onClick={(e) => e.stopPropagation()}>
<Label htmlFor="form-field-required">{t('required')}</Label>
<Switch
id="form-field-required"
className="ml-1 mr-2"
checked={required}
disabled={field.notNull}
onCheckedChange={onRequiredChange}
/>
{isProtected ? (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className="flex items-center">
<Switch
id="form-field-required"
className="ml-1 mr-2 cursor-not-allowed"
checked={required}
disabled={isProtected}
/>
<EyeOff className="size-6 cursor-not-allowed rounded p-1 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('table:form.protectedFieldTip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Switch
id="form-field-required"
className="ml-1 mr-2"
checked={required}
onCheckedChange={onRequiredChange}
/>
)}
</div>
)}
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span>
<EyeOff
className="size-6 cursor-pointer rounded p-1 hover:bg-slate-300 dark:hover:bg-slate-600"
onClick={onHidden}
/>
</span>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('table:form.removeFromFormTip')}</TooltipContent>
</Tooltip>
</TooltipProvider>
{!isProtected && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span>
<EyeOff
className="size-6 cursor-pointer rounded p-1 hover:bg-slate-300 dark:hover:bg-slate-600"
onClick={onHidden}
/>
</span>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('table:form.removeFromFormTip')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
{description && <div className="mb-2 text-xs text-slate-400">{description}</div>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export const FormBody = (props: IFormBodyProps) => {
{name ?? t('untitled')}
</div>

{description && <div className="mb-4 w-full px-12">{description}</div>}
{description && <div className="mb-4 w-full whitespace-pre-line px-12">{description}</div>}

{Boolean(visibleFields.length) && (
<div className="w-full px-6 sm:px-12">
Expand Down
8 changes: 8 additions & 0 deletions apps/nextjs-app/src/features/app/blocks/view/form/util.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
import type { IFieldInstance } from '@teable/sdk/model';

export const generateUniqLocalKey = (tableId?: string, viewId?: string) => `${tableId}-${viewId}`;

export const isProtectedField = (field: IFieldInstance) => {
const { options, notNull } = field;
const defaultValue = (options as { defaultValue?: string })?.defaultValue;
return Boolean(notNull) && !defaultValue;
};
3 changes: 2 additions & 1 deletion packages/common-i18n/src/locales/de/table.json
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,8 @@
"unableAddFieldTip": "Feld dieses Typs kann nicht hinzugefügt werden",
"removeFromFormTip": "Aus dem Formular entfernen",
"descriptionPlaceholder": "Aus Beschreibung eingeben",
"dragToFormTip": "Ziehen Sie das Feld hierher, um es dem Formular hinzuzufügen"
"dragToFormTip": "Ziehen Sie das Feld hierher, um es dem Formular hinzuzufügen",
"protectedFieldTip": "Dieses Feld wurde als \"Pflichtfeld\" festgelegt und kann in der Formularansicht nicht entfernt werden. Bitte ändern Sie es in den Feldeinstellungen."
},
"kanban": {
"toolbar": {
Expand Down
3 changes: 2 additions & 1 deletion packages/common-i18n/src/locales/en/table.json
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,8 @@
"unableAddFieldTip": "Unable to add this type field",
"removeFromFormTip": "Remove from the form",
"descriptionPlaceholder": "Enter from description",
"dragToFormTip": "Drag the field here to add it to the form"
"dragToFormTip": "Drag the field here to add it to the form",
"protectedFieldTip": "This field has been set as a \"required\" field, and cannot be removed in the form view. Please modify it in the field settings."
},
"kanban": {
"toolbar": {
Expand Down
3 changes: 2 additions & 1 deletion packages/common-i18n/src/locales/es/table.json
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,8 @@
"unableAddFieldTip": "No se puede agregar este tipo de campo",
"removeFromFormTip": "Eliminar del formulario",
"descriptionPlaceholder": "Ingresa la descripción del formulario",
"dragToFormTip": "Arrastra el campo aquí para agregarlo al formulario"
"dragToFormTip": "Arrastra el campo aquí para agregarlo al formulario",
"protectedFieldTip": "Este campo se ha establecido como campo \"requerido\" y no se puede eliminar en la vista de formulario. Por favor, modifíquelo en la configuración del campo."
},
"kanban": {
"toolbar": {
Expand Down
Loading
Loading