diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts index c987311fb2..8123950faf 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts @@ -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( diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts index 10ecc65e60..9a0d723381 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts @@ -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'; @@ -335,7 +336,18 @@ export class RecordOpenApiService { }); } - async buttonClick(tableId: string, recordId: string, fieldId: string) { + async buttonClick(tableId: string, recordId: string, fieldId: string): Promise { + 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 { const fieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({ where: { id: fieldId, @@ -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 { + 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 { + 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 { + 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 { 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) { diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index 19f25c45b2..8ca7f2e474 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -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; diff --git a/apps/nestjs-backend/test/field-converting.e2e-spec.ts b/apps/nestjs-backend/test/field-converting.e2e-spec.ts index 88021599bd..4108ed2e7d 100644 --- a/apps/nestjs-backend/test/field-converting.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-converting.e2e-spec.ts @@ -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', () => { diff --git a/apps/nextjs-app/.env.development b/apps/nextjs-app/.env.development index 7de7546c4b..89aedb07f8 100644 --- a/apps/nextjs-app/.env.development +++ b/apps/nextjs-app/.env.development @@ -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:teable@127.0.0.1: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 diff --git a/apps/nextjs-app/.env.test b/apps/nextjs-app/.env.test index dc80bf46ab..01a0640a85 100644 --- a/apps/nextjs-app/.env.test +++ b/apps/nextjs-app/.env.test @@ -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:teable@127.0.0.1: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 diff --git a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx index 60fed0ef21..07bd3cc74f 100644 --- a/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx +++ b/apps/nextjs-app/src/features/app/blocks/share/view/component/grid/GridViewBase.tsx @@ -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; diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx index 6612827072..526c7460a2 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx @@ -940,6 +940,13 @@ export const GridViewBaseInner: React.FC = ( 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; 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 6fd5858c18..9de30b9371 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 @@ -50,6 +50,7 @@ export interface IFieldOptionsProps { export const FieldOptions: React.FC = ({ field, onChange, onSave }) => { const { id, type, isLookup, cellValueType, isMultipleCellValue, options } = field; + const fields = useFields({ withHidden: true, withDenied: true }); const lookupField = useRollupLookupField(field.lookupOptions); const lookupCellValueType = useMemo( () => normalizeCellValueType(lookupField?.cellValueType), @@ -190,6 +191,11 @@ export const FieldOptions: React.FC = ({ field, onChange, on isLookup={isLookup} onChange={onChange} onSave={onSave} + fields={fields.map((field) => ({ + id: field.id, + name: field.name, + type: field.type, + }))} /> ); default: diff --git a/apps/nextjs-app/src/features/app/components/field-setting/options/ButtonOptions.tsx b/apps/nextjs-app/src/features/app/components/field-setting/options/ButtonOptions.tsx index a1f2070a30..9e778b11b4 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/options/ButtonOptions.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/options/ButtonOptions.tsx @@ -1,5 +1,6 @@ import { Colors, ColorUtils } from '@teable/core'; -import type { IButtonFieldOptions } from '@teable/core'; +import type { IButtonFieldOptions, FieldType } from '@teable/core'; +import { useFieldStaticGetter } from '@teable/sdk'; import { Button, Input, @@ -7,13 +8,18 @@ import { Popover, PopoverContent, PopoverTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@teable/ui-lib/shadcn'; -import { PencilIcon, PlusIcon } from 'lucide-react'; +import { ExternalLinkIcon, PencilIcon, PlusIcon } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useWorkFlowPanelStore } from '@/features/app/automation/workflow-panel/useWorkFlowPaneStore'; @@ -75,16 +81,138 @@ const WorkflowAction = (props: { options?: Partial; onSave? ); }; +const OpenLinkAction = (props: { + options?: Partial; + onChange?: (options: Partial) => void; + fields?: Array<{ id: string; name: string; type: string }>; +}) => { + const { options, onChange, fields = [] } = props; + const { t } = useTranslation(tableConfig.i18nNamespaces); + const getFieldStatic = useFieldStaticGetter(); + const [urlMode, setUrlMode] = useState<'manual' | 'field'>( + options?.url && options.url.startsWith('{') && options.url.endsWith('}') ? 'field' : 'manual' + ); + + // Filter for text and formula fields + const textFields = fields.filter( + (field) => + field.type === 'singleLineText' || field.type === 'formula' || field.type === 'longText' + ); + + const handleUrlChange = (value: string) => { + onChange?.({ ...options, url: value }); + }; + + const handleFieldSelect = (fieldId: string) => { + onChange?.({ ...options, url: `{${fieldId}}` }); + }; + + const currentFieldValue = options?.url; + const selectedFieldId = + currentFieldValue?.startsWith('{') && currentFieldValue.endsWith('}') + ? currentFieldValue.slice(1, -1) + : ''; + + return ( +
+ + +
+ + + {urlMode === 'manual' ? ( + handleUrlChange(e.target.value)} + className="flex-1" + /> + ) : ( + + )} +
+ +
+ onChange?.({ ...options, openInNewTab: checked })} + /> + +
+
+ ); +}; + export const ButtonOptions = (props: { options: Partial | undefined; onChange?: (options: Partial) => void; isLookup?: boolean; onSave?: () => void; + fields?: Array<{ id: string; name: string; type: string }>; }) => { - const { isLookup, options, onChange, onSave } = props; + const { isLookup, options, onChange, onSave, fields } = props; const { t } = useTranslation(tableConfig.i18nNamespaces); const bgColor = ColorUtils.getHexForColor(options?.color ?? Colors.Teal); const [limitClickCount, setLimitClickCount] = useState((options?.maxCount ?? 0) > 0); + const [action, setAction] = useState<'workflow' | 'openLink'>(options?.action || 'workflow'); return (
@@ -114,56 +242,109 @@ export const ButtonOptions = (props: { onChange?.({ ...options, label: e.target.value })} />
- -
-
- { - setLimitClickCount(checked); - onChange?.({ ...options, maxCount: checked ? 1 : 0 }); - }} - /> - -
+ + +
+ + {action === 'workflow' ? ( + + ) : ( + + )} - {limitClickCount && ( + {/* Click count limit - only show for workflow action */} + {action === 'workflow' && ( +
onChange?.({ ...options, resetCount: checked })} + checked={limitClickCount} + onCheckedChange={(checked) => { + setLimitClickCount(checked); + onChange?.({ ...options, maxCount: checked ? 1 : 0 }); + }} />
- )} - {limitClickCount && ( -
- - - onChange?.({ ...options, maxCount: Math.max(0, Number(e.target.value)) }) - } - /> -
- )} -
+ {limitClickCount && ( +
+ onChange?.({ ...options, resetCount: checked })} + /> + +
+ )} + + {limitClickCount && ( +
+ + + onChange?.({ ...options, maxCount: Math.max(0, Number(e.target.value)) }) + } + /> +
+ )} + + )} )} diff --git a/apps/nextjs-app/src/features/app/components/field-setting/options/__tests__/ButtonOptions.test.tsx b/apps/nextjs-app/src/features/app/components/field-setting/options/__tests__/ButtonOptions.test.tsx new file mode 100644 index 0000000000..8e4b00dfbb --- /dev/null +++ b/apps/nextjs-app/src/features/app/components/field-setting/options/__tests__/ButtonOptions.test.tsx @@ -0,0 +1,263 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ButtonOptions } from '../ButtonOptions'; + +// Mock the i18n hook +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, // Simply return the key as translation + }), +})); + +// Mock the workflow store +vi.mock('@/features/app/automation/workflow-panel/useWorkFlowPaneStore', () => ({ + useWorkFlowPanelStore: () => ({ + setModal: vi.fn(), + }), +})); + +// Mock the base usage hook +vi.mock('@/features/app/hooks/useBaseUsage', () => ({ + useBaseUsage: () => ({ + limit: { + automationEnable: true, + }, + }), +})); + +describe('ButtonOptions', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + mockOnChange.mockClear(); + }); + + it('renders workflow action by default', () => { + render(); + + expect(screen.getByText('table:field.default.button.action')).toBeInTheDocument(); + expect(screen.getByText('table:field.default.button.triggerWorkflow')).toBeInTheDocument(); + }); + + it('switches to openLink action when selected', () => { + render(); + + const selectTrigger = screen.getByRole('combobox'); + fireEvent.click(selectTrigger); + + const openLinkOption = screen.getByText('table:field.default.button.openLink'); + fireEvent.click(openLinkOption); + + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'openLink', + workflow: undefined, + }) + ); + + expect(screen.getByPlaceholderText('https://example.com')).toBeInTheDocument(); + expect(screen.getByText('table:field.default.button.openInNewTab')).toBeInTheDocument(); + }); + + it('renders openLink action when set in options', () => { + const options = { + action: 'openLink' as const, + url: 'https://test.com', + openInNewTab: false, + }; + + render(); + + expect(screen.getByDisplayValue('https://test.com')).toBeInTheDocument(); + + // Check that the switch is off + const switchElement = screen.getByRole('switch'); + expect(switchElement).not.toBeChecked(); + }); + + it('updates URL when changed', () => { + const options = { + action: 'openLink' as const, + url: 'https://test.com', + }; + + render(); + + const urlInput = screen.getByDisplayValue('https://test.com'); + fireEvent.change(urlInput, { target: { value: 'https://new-url.com' } }); + + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://new-url.com', + }) + ); + }); + + it('toggles openInNewTab option', () => { + const options = { + action: 'openLink' as const, + url: 'https://test.com', + openInNewTab: true, + }; + + render(); + + const switchElement = screen.getByRole('switch'); + expect(switchElement).toBeChecked(); + + fireEvent.click(switchElement); + + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + openInNewTab: false, + }) + ); + }); + + it('switches from openLink back to workflow', () => { + const options = { + action: 'openLink' as const, + url: 'https://test.com', + openInNewTab: true, + }; + + render(); + + const selectTrigger = screen.getByRole('combobox'); + fireEvent.click(selectTrigger); + + const workflowOption = screen.getByText('table:field.default.button.triggerWorkflow'); + fireEvent.click(workflowOption); + + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow', + }) + ); + + expect(mockOnChange).toHaveBeenCalledWith( + expect.not.objectContaining({ + url: expect.any(String), + }) + ); + }); + + it('does not render action selection for lookup fields', () => { + render(); + + expect(screen.queryByText('table:field.default.button.action')).not.toBeInTheDocument(); + expect( + screen.queryByText('table:field.default.button.triggerWorkflow') + ).not.toBeInTheDocument(); + expect(screen.queryByText('table:field.default.button.openLink')).not.toBeInTheDocument(); + }); + + it('does not render click count limit for openLink action', () => { + const options = { + action: 'openLink' as const, + url: 'https://test.com', + }; + + render(); + + expect(screen.queryByText('table:field.default.button.limitCount')).not.toBeInTheDocument(); + expect(screen.queryByText('table:field.default.button.resetCount')).not.toBeInTheDocument(); + expect(screen.queryByText('table:field.default.button.maxCount')).not.toBeInTheDocument(); + }); + + it('renders manual URL mode by default', () => { + const options = { + action: 'openLink' as const, + url: 'https://test.com', + }; + + render(); + + expect(screen.getByText('table:field.default.button.manualUrl')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://test.com')).toBeInTheDocument(); + }); + + it('renders field mode when URL is a field reference', () => { + const options = { + action: 'openLink' as const, + url: '{field123}', + }; + + const fields = [ + { id: 'field123', name: 'Website URL', type: 'singleLineText' }, + { id: 'field456', name: 'Formula', type: 'formula' }, + ]; + + render(); + + expect(screen.getByText('table:field.default.button.fieldUrl')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Website URL (SingleLineText)')).toBeInTheDocument(); + }); + + it('switches between manual and field URL modes', () => { + const options = { + action: 'openLink' as const, + url: 'https://example.com', + }; + + const fields = [{ id: 'field123', name: 'Dynamic Link', type: 'singleLineText' }]; + + render(); + + // Initially in manual mode + expect(screen.getByText('table:field.default.button.manualUrl')).toBeInTheDocument(); + + // Switch to field mode + const modeSelect = screen.getAllByRole('combobox')[0]; + fireEvent.click(modeSelect); + fireEvent.click(screen.getByText('table:field.default.button.fieldUrl')); + + expect(screen.getByText('table:field.default.button.fieldUrl')).toBeInTheDocument(); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + url: '{field123}', + }) + ); + }); + + it('renders click count limit for workflow action', () => { + const options = { + action: 'workflow' as const, + workflow: { + id: 'workflow_123', + name: 'Test Workflow', + isActive: true, + }, + }; + + render(); + + expect(screen.getByText('table:field.default.button.limitCount')).toBeInTheDocument(); + }); + + it('clears workflow when switching to openLink', () => { + const options = { + action: 'workflow' as const, + workflow: { + id: 'workflow_123', + name: 'Test Workflow', + isActive: true, + }, + }; + + render(); + + const selectTrigger = screen.getByRole('combobox'); + fireEvent.click(selectTrigger); + + const openLinkOption = screen.getByText('table:field.default.button.openLink'); + fireEvent.click(openLinkOption); + + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'openLink', + workflow: undefined, + }) + ); + }); +}); diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index d18b4ed718..52e7dc6cec 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -137,6 +137,16 @@ "title": "Button", "label": "Button label", "color": "Button color", + "action": "Button action", + "triggerWorkflow": "Trigger workflow", + "openLink": "Open link", + "linkUrl": "Link URL", + "manualUrl": "Manual URL", + "fieldUrl": "Field value", + "selectField": "Select a field", + "noTextFields": "No text or formula fields available", + "fieldUrlDescription": "Will use the value from field: {{fieldName}}", + "openInNewTab": "Open in new tab", "limitCount": "Limit clicks", "resetCount": "Allow reset", "maxCount": "Max clicks", diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 367a960f14..a87f1af42b 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -138,6 +138,16 @@ "title": "按钮", "label": "按钮文字", "color": "按钮颜色", + "action": "按钮动作", + "triggerWorkflow": "触发工作流", + "openLink": "打开链接", + "linkUrl": "链接地址", + "manualUrl": "手动输入链接", + "fieldUrl": "字段值", + "selectField": "选择字段", + "noTextFields": "没有可用的文本或公式字段", + "fieldUrlDescription": "将使用字段 {{fieldName}} 的值", + "openInNewTab": "在新标签页中打开", "limitCount": "限制点击次数", "resetCount": "允许重置点击计数", "maxCount": "最大点击计数", diff --git a/packages/core/src/models/field/button-utils.ts b/packages/core/src/models/field/button-utils.ts index 0d841ac372..a18f4cc2fd 100644 --- a/packages/core/src/models/field/button-utils.ts +++ b/packages/core/src/models/field/button-utils.ts @@ -5,6 +5,15 @@ export const checkButtonClickable = ( fieldOptions: IButtonFieldOptions, cellValue?: IButtonFieldCellValue ) => { + const action = fieldOptions.action || 'workflow'; + + // For openLink action, we only need to check URL (no click count limit) + if (action === 'openLink') { + // URL can be a hardcoded value or a field reference like {fieldId} + return Boolean(fieldOptions.url); + } + + // For workflow action (existing logic) const workflow = fieldOptions.workflow; if (!workflow) { return false; diff --git a/packages/core/src/models/field/derivate/button-option.schema.ts b/packages/core/src/models/field/derivate/button-option.schema.ts index 454d4d6107..f0fc299b9e 100644 --- a/packages/core/src/models/field/derivate/button-option.schema.ts +++ b/packages/core/src/models/field/derivate/button-option.schema.ts @@ -7,6 +7,19 @@ export const buttonFieldOptionsSchema = z.object({ color: z.nativeEnum(Colors).openapi({ description: 'Button color' }), maxCount: z.number().optional().openapi({ description: 'Max count of button clicks' }), resetCount: z.boolean().optional().openapi({ description: 'Reset count' }), + action: z + .enum(['workflow', 'openLink']) + .default('workflow') + .openapi({ description: 'Button action type' }), + url: z.string().optional().openapi({ + description: + 'URL to open when action is openLink (can be a hardcoded URL or field reference like {fieldId}', + }), + openInNewTab: z + .boolean() + .default(true) + .optional() + .openapi({ description: 'Open URL in new tab' }), workflow: z .object({ id: z diff --git a/packages/core/src/models/field/derivate/button.field.ts b/packages/core/src/models/field/derivate/button.field.ts index 83037844be..5822769762 100644 --- a/packages/core/src/models/field/derivate/button.field.ts +++ b/packages/core/src/models/field/derivate/button.field.ts @@ -25,6 +25,7 @@ export class ButtonFieldCore extends FieldCore { return { label: 'Button', color: Colors.Teal, + action: 'workflow', }; } diff --git a/packages/openapi/src/record/button-click.ts b/packages/openapi/src/record/button-click.ts index 54abc0c84c..9df99952ad 100644 --- a/packages/openapi/src/record/button-click.ts +++ b/packages/openapi/src/record/button-click.ts @@ -8,10 +8,13 @@ import { registerRoute, urlBuilder } from '../utils'; export const BUTTON_CLICK = '/table/{tableId}/record/{recordId}/{fieldId}/button-click'; export const buttonClickVoSchema = z.object({ - runId: z.string(), + runId: z.string().optional(), tableId: z.string(), fieldId: z.string(), record: recordSchema, + action: z.enum(['workflow', 'openLink']).optional(), + url: z.string().optional(), + openInNewTab: z.boolean().optional(), }); export type IButtonClickVo = z.infer; diff --git a/packages/sdk/src/hooks/use-button-click-status.ts b/packages/sdk/src/hooks/use-button-click-status.ts index f33e62d21c..f4e916dd5e 100644 --- a/packages/sdk/src/hooks/use-button-click-status.ts +++ b/packages/sdk/src/hooks/use-button-click-status.ts @@ -37,8 +37,40 @@ export const useButtonClickStatus = (tableId: string, shareId?: string) => { ? shareViewButtonClickApi(shareId, ro.recordId, ro.fieldId) : buttonClickApi(ro.tableId, ro.recordId, ro.fieldId), onSuccess: (res, ro) => { + // Handle openLink action + if (res.data.action === 'openLink' && res.data.url) { + let url = res.data.url; + + // Convert relative URLs to absolute URLs + if ( + url && + !url.startsWith('http://') && + !url.startsWith('https://') && + !url.startsWith('/') + ) { + url = `https://${url}`; + } + + if (res.data.openInNewTab) { + window.open(url, '_blank', 'noopener,noreferrer'); + } else { + window.location.href = url; + } + + // For openLink action, we don't need to show loading status + setComplated({ + runId: '', + recordId: ro.recordId, + fieldId: ro.fieldId, + loading: false, + name: ro.name, + }); + return; + } + + // Handle workflow action (existing logic) setStatus({ - runId: res.data.runId, + runId: res.data.runId || '', recordId: ro.recordId, fieldId: ro.fieldId, loading: !!res.data.runId,