WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content
Open
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 @@ -846,10 +846,20 @@ export class FieldConvertingService {
) {
const oldWorkflow = oldField.options.workflow;
const newWorkflow = newField.options.workflow;
const { action: oldAction = 'workflow' } = oldField.options;
const { action: newAction = 'workflow' } = newField.options;
const oldUrl = oldField.options.url;
const newUrl = newField.options.url;

if (oldWorkflow?.id === newWorkflow?.id) return;
// Check if workflow changed
if (oldWorkflow?.id !== newWorkflow?.id) {
return await this.updateOptionsFromButtonField(tableId, oldField);
}

return await this.updateOptionsFromButtonField(tableId, oldField);
// Check if action changed or URL changed for openLink action
if (oldAction !== newAction || oldUrl !== newUrl) {
return await this.updateOptionsFromButtonField(tableId, oldField);
}
}

private async modifyOptions(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
IRecordHistoryVo,
IRecordInsertOrderRo,
IUpdateRecordRo,
IButtonClickVo,
} from '@teable/openapi';
import { keyBy, pick } from 'lodash';
import { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';
Expand Down Expand Up @@ -335,7 +336,18 @@ export class RecordOpenApiService {
});
}

async buttonClick(tableId: string, recordId: string, fieldId: string) {
async buttonClick(tableId: string, recordId: string, fieldId: string): Promise<IButtonClickVo> {
const options = await this.getButtonFieldOptions(fieldId);
const action = options.action || 'workflow';

if (action === 'openLink') {
return this.handleOpenLinkAction(tableId, recordId, fieldId, options);
}

return this.handleWorkflowAction(tableId, recordId, fieldId, options);
}

private async getButtonFieldOptions(fieldId: string): Promise<IButtonFieldOptions> {
const fieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({
where: {
id: fieldId,
Expand All @@ -345,37 +357,146 @@ export class RecordOpenApiService {
});

const fieldInstance = createFieldInstanceByRaw(fieldRaw);
const options = fieldInstance.options as IButtonFieldOptions;
return fieldInstance.options as IButtonFieldOptions;
}

private async handleOpenLinkAction(
tableId: string,
recordId: string,
fieldId: string,
options: IButtonFieldOptions
): Promise<IButtonClickVo> {
if (!options.url) {
throw new BadRequestException('URL is not configured for openLink action');
}

const record = await this.recordService.getRecord(tableId, recordId, {
fieldKeyType: FieldKeyType.Id,
});

const resolvedUrl = this.resolveUrlFromField(options.url, record.fields);
this.validateUrlFormat(options.url, resolvedUrl);

// Only return the field value, don't increment count for openLink action
const recordWithFieldOnly: IRecord = {
...record,
fields: pick(record.fields, [fieldId]),
};

return {
tableId,
fieldId,
record: recordWithFieldOnly,
action: 'openLink',
url: resolvedUrl,
openInNewTab: options.openInNewTab ?? true,
};
}

private resolveUrlFromField(url: string, fields: Record<string, unknown>): string {
if (!url.startsWith('{') || !url.endsWith('}')) {
return url;
}

const referencedFieldId = url.slice(1, -1);
const referencedFieldValue = fields[referencedFieldId];

if (referencedFieldValue === null || referencedFieldValue === undefined) {
throw new BadRequestException(`Field ${referencedFieldId} has no value for URL`);
}

if (typeof referencedFieldValue === 'string') {
return referencedFieldValue;
}

if (Array.isArray(referencedFieldValue) && referencedFieldValue.length > 0) {
return String(referencedFieldValue[0]);
}

return String(referencedFieldValue);
}

private validateUrlFormat(originalUrl: string, resolvedUrl: string): void {
if (originalUrl.startsWith('{')) {
return; // Skip validation for field references
}

try {
new URL(resolvedUrl);
} catch {
throw new BadRequestException(`Invalid URL format: ${resolvedUrl}`);
}
}

private async handleWorkflowAction(
tableId: string,
recordId: string,
fieldId: string,
options: IButtonFieldOptions
): Promise<IButtonClickVo> {
this.validateWorkflowIsActive(options);

const record = await this.recordService.getRecord(tableId, recordId, {
fieldKeyType: FieldKeyType.Id,
});

this.validateButtonClickCount(record.fields[fieldId] as IButtonFieldCellValue, options);

const updatedRecord = await this.incrementButtonCount(tableId, recordId, fieldId);

return {
tableId,
fieldId,
record: updatedRecord,
action: 'workflow',
};
}

private validateWorkflowIsActive(options: IButtonFieldOptions): void {
const isActive = options.workflow && options.workflow.id && options.workflow.isActive;
if (!isActive) {
throw new BadRequestException(
`Button field's workflow ${options.workflow?.id} is not active`
);
}
}

private validateButtonClickCount(
fieldValue: IButtonFieldCellValue | undefined,
options: IButtonFieldOptions
): void {
const maxCount = options.maxCount || 0;
if (maxCount === 0) {
return;
}

const count = fieldValue?.count || 0;
if (count >= maxCount) {
throw new BadRequestException(`Button click count ${count} reached max count ${maxCount}`);
}
}

private async incrementButtonCount(
tableId: string,
recordId: string,
fieldId: string
): Promise<IRecord> {
const record = await this.recordService.getRecord(tableId, recordId, {
fieldKeyType: FieldKeyType.Id,
});

const fieldValue = record.fields[fieldId] as IButtonFieldCellValue;
const count = fieldValue?.count || 0;
if (maxCount > 0 && count >= maxCount) {
throw new BadRequestException(`Button click count ${count} reached max count ${maxCount}`);
}

const updatedRecord: IRecord = await this.updateRecord(tableId, recordId, {
record: {
fields: { [fieldId]: { count: count + 1 } },
},
fieldKeyType: FieldKeyType.Id,
});
updatedRecord.fields = pick(updatedRecord.fields, [fieldId]);

return {
tableId,
fieldId,
record: updatedRecord,
};
updatedRecord.fields = pick(updatedRecord.fields, [fieldId]);
return updatedRecord;
}

async resetButton(tableId: string, recordId: string, fieldId: string) {
Expand Down
10 changes: 10 additions & 0 deletions apps/nestjs-backend/src/types/i18n.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2619,6 +2619,16 @@ export type I18nTranslations = {
"maxCount": string;
"automation": string;
"customAutomation": string;
"action": string;
"triggerWorkflow": string;
"openLink": string;
"linkUrl": string;
"manualUrl": string;
"fieldUrl": string;
"selectField": string;
"noTextFields": string;
"fieldUrlDescription": string;
"openInNewTab": string;
};
"formula": {
"title": string;
Expand Down
107 changes: 107 additions & 0 deletions apps/nestjs-backend/test/field-converting.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4500,6 +4500,113 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => {

expect(newRecords[0].fields[buttonField.id]).toBeUndefined();
});

it('should handle button field with openLink action', async () => {
const buttonFieldRo: IFieldRo = {
type: FieldType.Button,
options: {
label: 'Open Google',
color: Colors.Blue,
action: 'openLink',
url: 'https://www.google.com',
openInNewTab: true,
},
};

const buttonField = await createField(table1.id, buttonFieldRo);

// Test button click with openLink action
const clickRes = await buttonClick(table1.id, table1.records[0].id, buttonField.id);

expect(clickRes.status).toEqual(200);
expect(clickRes.data.action).toEqual('openLink');
expect(clickRes.data.url).toEqual('https://www.google.com');
expect(clickRes.data.openInNewTab).toEqual(true);

// Check that click count is incremented
const clickValue = clickRes.data.record.fields[buttonField.id] as IButtonFieldCellValue;
expect(clickValue.count).toEqual(1);

// Test second click
const clickRes2 = await buttonClick(table1.id, table1.records[0].id, buttonField.id);
const clickValue2 = clickRes2.data.record.fields[buttonField.id] as IButtonFieldCellValue;
expect(clickValue2.count).toEqual(2);
});

it('should handle button field with openLink action in current tab', async () => {
const buttonFieldRo: IFieldRo = {
type: FieldType.Button,
options: {
label: 'Open Example',
color: Colors.Green,
action: 'openLink',
url: 'https://example.com',
openInNewTab: false,
},
};

const buttonField = await createField(table1.id, buttonFieldRo);

const clickRes = await buttonClick(table1.id, table1.records[0].id, buttonField.id);

expect(clickRes.status).toEqual(200);
expect(clickRes.data.action).toEqual('openLink');
expect(clickRes.data.url).toEqual('https://example.com');
expect(clickRes.data.openInNewTab).toEqual(false);
});

it('should fail when openLink action has no URL', async () => {
const buttonFieldRo: IFieldRo = {
type: FieldType.Button,
options: {
label: 'Broken Link',
color: Colors.Red,
action: 'openLink',
// Missing URL
},
};

const buttonField = await createField(table1.id, buttonFieldRo);

// Should fail when trying to click without URL
await expect(buttonClick(table1.id, table1.records[0].id, buttonField.id)).rejects.toThrow(
'URL is not configured for openLink action'
);
});

it('should convert button field from workflow to openLink action', async () => {
const buttonFieldRo1: IFieldRo = {
type: FieldType.Button,
options: {
label: 'Workflow Button',
color: Colors.Purple,
action: 'workflow',
workflow: {
id: generateWorkflowId(),
name: 'workflow1',
isActive: true,
},
},
};

const buttonFieldRo2: IFieldRo = {
type: FieldType.Button,
options: {
label: 'Link Button',
color: Colors.Blue,
action: 'openLink',
url: 'https://teable.io',
openInNewTab: true,
},
};

const { newField } = await expectUpdate(table1, buttonFieldRo1, buttonFieldRo2);
const options = newField.options as IButtonFieldOptions;
expect(options.action).toEqual('openLink');
expect(options.url).toEqual('https://teable.io');
expect(options.openInNewTab).toEqual(true);
expect(options.workflow).toBeUndefined();
});
});

describe('modify primary field', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/nextjs-app/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ PUBLIC_ORIGIN=http://localhost:3000
I18N_TYPES_OUTPUT_PATH=./src/types/i18n.generated.ts
# DATABASE_URL
# @see https://www.prisma.io/docs/reference/database-reference/connection-urls#examples
PRISMA_DATABASE_URL=postgresql://teable:[email protected]:5432/teable?schema=public&statement_cache_size=1
PRISMA_DATABASE_URL=file:../../db/main.db
PUBLIC_DATABASE_PROXY=127.0.0.1:5432

API_DOC_DISENABLED=false
Expand Down
2 changes: 1 addition & 1 deletion apps/nextjs-app/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ STORAGE_PREFIX=http://127.0.0.1:3000

# DATABASE_URL
# @see https://www.prisma.io/docs/reference/database-reference/connection-urls#examples
PRISMA_DATABASE_URL=postgresql://teable:[email protected]:5432/teable?schema=public&statement_cache_size=1
PRISMA_DATABASE_URL=file:../../db/main.db
PUBLIC_DATABASE_PROXY=127.0.0.1:5432
BACKEND_CACHE_PROVIDER=memory

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,13 @@ export const GridViewBase = (props: IGridViewProps) => {
if (cellInfo.type === CellType.Button) {
const { data } = cellInfo as IButtonCell;
const { fieldOptions, cellValue } = data;
const action = fieldOptions.action || 'workflow';

// Don't show click count for openLink action
if (action === 'openLink') {
return;
}

const { label } = fieldOptions;
const count = cellValue?.count ?? 0;
const maxCount = fieldOptions?.maxCount ?? 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,13 @@ export const GridViewBaseInner: React.FC<IGridViewBaseInnerProps> = (
if (cellInfo.type === CellType.Button) {
const { data } = cellInfo as IButtonCell;
const { fieldOptions, cellValue } = data;
const action = fieldOptions.action || 'workflow';

// Don't show click count for openLink action
if (action === 'openLink') {
return;
}

const { label } = fieldOptions;
const count = cellValue?.count ?? 0;
const maxCount = fieldOptions?.maxCount ?? 0;
Expand Down
Loading
Loading