This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 8043cf785 fix(web): frontend input and data fidelity (#2200)
8043cf785 is described below
commit 8043cf785c57be9e2b6f29c88e32c1720412eeaa
Author: shown <[email protected]>
AuthorDate: Wed Aug 19 11:19:14 2026 +0800
fix(web): frontend input and data fidelity (#2200)
* fix(web): preserve AI prompt submission integrity
* fix(web): preserve CSV import round-trip semantics
* fix(web): normalize producer connection summary identity
* fix(web): validate pasted message properties
* fix(lite-topic): clear stale quota data
Signed-off-by: yuluo-yx <[email protected]>
* fix(producer): guard query request lifecycle
Signed-off-by: yuluo-yx <[email protected]>
---------
Signed-off-by: yuluo-yx <[email protected]>
---
web/src/api/producer.test.ts | 22 ++++++++
web/src/api/producer.ts | 8 ++-
web/src/pages/ai/__tests__/AiPage.test.tsx | 42 ++++++++++++++-
web/src/pages/ai/index.tsx | 6 ++-
web/src/pages/home/__tests__/HomePage.test.tsx | 13 ++++-
web/src/pages/home/index.tsx | 1 +
.../pages/instance/__tests__/TopicPage.test.tsx | 15 ++++++
web/src/pages/instance/topic.tsx | 21 +++-----
web/src/pages/studio/LiteTopic.tsx | 3 ++
web/src/pages/studio/Producer.tsx | 9 ++++
web/src/pages/studio/__tests__/LiteTopic.test.tsx | 21 ++++++++
web/src/pages/studio/__tests__/Producer.test.tsx | 62 ++++++++++++++++++++++
web/src/utils/messageProperties.ts | 45 ++++++++++++++++
web/src/utils/resourceCsvImport.test.ts | 17 ++++++
web/src/utils/resourceCsvImport.ts | 4 +-
15 files changed, 268 insertions(+), 21 deletions(-)
diff --git a/web/src/api/producer.test.ts b/web/src/api/producer.test.ts
index 26b39f9bf..54cbfabce 100644
--- a/web/src/api/producer.test.ts
+++ b/web/src/api/producer.test.ts
@@ -169,4 +169,26 @@ describe('Producer API', () => {
expect(result.duplicateClientIds).toEqual(['producer-a']);
expect(result.warnings).toEqual(['DUPLICATE_CLIENT_ID',
'MIXED_CLIENT_VERSION']);
});
+
+ it('normalizes client identifiers and addresses consistently in summary
counts', () => {
+ const result = buildProducerConnectionSummary([
+ {
+ clientId: 'producer-a',
+ clientAddr: '10.0.0.1',
+ language: 'Java',
+ versionDesc: '5.1.0',
+ },
+ {
+ clientId: ' producer-a ',
+ clientAddr: ' 10.0.0.1 ',
+ language: 'Java',
+ versionDesc: '5.1.0',
+ },
+ ]);
+
+ expect(result.uniqueClientCount).toBe(1);
+ expect(result.uniqueAddressCount).toBe(1);
+ expect(result.duplicateClientIds).toEqual(['producer-a']);
+ expect(result.warnings).toContain('DUPLICATE_CLIENT_ID');
+ });
});
diff --git a/web/src/api/producer.ts b/web/src/api/producer.ts
index 5249a8706..26b458853 100644
--- a/web/src/api/producer.ts
+++ b/web/src/api/producer.ts
@@ -76,7 +76,13 @@ const normalizeDimension = (value?: string | null) =>
(hasText(value) ? value!.t
const countDistinct = (
connections: ProducerConnection[],
extractor: (connection: ProducerConnection) => string,
-) => new Set(connections.map(extractor).filter(hasText)).size;
+) =>
+ new Set(
+ connections
+ .map(extractor)
+ .filter(hasText)
+ .map((value) => value.trim()),
+ ).size;
const distribution = (
connections: ProducerConnection[],
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 863318e53..5c782c6f4 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -288,6 +288,36 @@ describe('AiPage tool runner', () => {
await waitFor(() => expect(screen.queryByRole('button', { name: '停止'
})).not.toBeInTheDocument());
});
+ it('does not send while an input method composition is being confirmed',
async () => {
+ renderPage();
+ const input = await screen.findByPlaceholderText(
+ '输入你的问题或指令,例如:查看集群状态、创建 Topic、诊断消费延迟...',
+ );
+ await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+
+ fireEvent.change(input, { target: { value: '检查集群状态' } });
+ fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
+
+ expect(chatStream).not.toHaveBeenCalled();
+ });
+
+ it('deduplicates prompt submissions before loading state is rendered', async
() => {
+ vi.mocked(chatStream).mockReturnValue(new Promise(() => {}));
+ renderPage();
+ const input = await screen.findByPlaceholderText(
+ '输入你的问题或指令,例如:查看集群状态、创建 Topic、诊断消费延迟...',
+ );
+ await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+ fireEvent.change(input, { target: { value: '检查集群状态' } });
+
+ await act(async () => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter',
bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter',
bubbles: true }));
+ });
+
+ expect(chatStream).toHaveBeenCalledTimes(1);
+ });
+
it('loads the catalog, creates a schema template, and renders structured
output', async () => {
const user = userEvent.setup();
vi.mocked(executeTool).mockResolvedValue({
@@ -325,8 +355,16 @@ describe('AiPage tool runner', () => {
let resolveOld!: (value: typeof oldTools) => void;
let resolveLatest!: (value: typeof latestTools) => void;
vi.mocked(listTools)
- .mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; }))
- .mockReturnValueOnce(new Promise((resolve) => { resolveLatest = resolve;
}));
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveOld = resolve;
+ }),
+ )
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveLatest = resolve;
+ }),
+ );
const user = userEvent.setup();
renderPage();
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 4ec8da584..3e6c3f577 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -462,6 +462,7 @@ const AiPage = () => {
const streamRequestIdRef = useRef(0);
const previousChatModeRef = useRef(chatMode);
const conversationIdRef = useRef<string |
null>(history.activeConversationId);
+ const chatInFlightRef = useRef(false);
const toolLoadRequestRef = useRef(0);
const consumedDraftRef = useRef(false);
const pendingAutoSendRef = useRef<{
@@ -591,11 +592,12 @@ const AiPage = () => {
) => {
const text = (textOverride ?? inputValue).trim();
const model = modelOverride ?? selectedModel;
- if (!text || loading) return;
+ if (!text || loading || chatInFlightRef.current) return;
if (!llmReady) {
message.warning(t('ai.providerRequired'));
return;
}
+ chatInFlightRef.current = true;
if (!conversationIdRef.current) {
conversationIdRef.current = newConversationId();
@@ -675,6 +677,7 @@ const AiPage = () => {
message.error(errorMessage);
}
} finally {
+ chatInFlightRef.current = false;
if (abortControllerRef.current === controller)
abortControllerRef.current = null;
updateMessages(chatMode, conversationId, (prev) =>
prev.map((item) => (item.id === responseId ? { ...item, pending:
false } : item)),
@@ -700,6 +703,7 @@ const AiPage = () => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+ if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
diff --git a/web/src/pages/home/__tests__/HomePage.test.tsx
b/web/src/pages/home/__tests__/HomePage.test.tsx
index 035e8ae7e..c2fe44391 100644
--- a/web/src/pages/home/__tests__/HomePage.test.tsx
+++ b/web/src/pages/home/__tests__/HomePage.test.tsx
@@ -16,7 +16,7 @@
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
@@ -111,4 +111,15 @@ describe('HomePage LLM models', () => {
});
});
});
+
+ it('does not submit while an input method composition is being confirmed',
async () => {
+ renderHome();
+ await screen.findByText('qwen3.8-max');
+ const input = screen.getByPlaceholderText('向 RocketMQ Bot 提问,全程加密、安全、可信');
+
+ fireEvent.change(input, { target: { value: '查看集群状态' } });
+ fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
+
+ expect(navigateMock).not.toHaveBeenCalled();
+ });
});
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index 90b32dacb..21b081c2c 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -186,6 +186,7 @@ const HomePage = () => {
/* ─── Keyboard shortcut: Enter to send ─── */
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+ if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handlePromptSubmit();
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index d4562a259..6a61755f1 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -22,6 +22,7 @@ import { MemoryRouter, Route, Routes } from
'react-router-dom';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
import type { Topic } from '../../../api/metadata';
+import { parseMessageProperties } from '../../../utils/messageProperties';
import TopicPage from '../topic';
const topicServiceMocks = vi.hoisted(() => ({
@@ -478,6 +479,20 @@ describe('TopicPage', () => {
expect(screen.getByRole('button', { name: /创建 Topic/ })).toBeDisabled();
});
+ it('rejects malformed or duplicate batch message properties without
sending', () => {
+
expect(parseMessageProperties('traceId=abc\ntenant\ntraceId=duplicate')).toEqual({
+ properties: { traceId: 'abc' },
+ errors: ['“tenant”应使用 key=value 格式', '属性名“traceId”重复'],
+ });
+ });
+
+ it('preserves equals signs in valid batch message property values', () => {
+ expect(parseMessageProperties('signature=part-a=part-b')).toEqual({
+ properties: { signature: 'part-a=part-b' },
+ errors: [],
+ });
+ });
+
it('renders unavailable Topic consumer metrics distinctly from zero', async
() => {
const user = userEvent.setup();
mockTopicsList([buildTopics(1)[0]]);
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index b6eb6575b..49ecfc123 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -77,6 +77,7 @@ import {
type ResourceImportRow,
} from '../../utils/resourceCsvImport';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { parseMessageProperties } from '../../utils/messageProperties';
const { Text } = Typography;
@@ -261,19 +262,6 @@ const RANDOM_BODY_GENERATORS = [
// ─── Format helpers ───────────────────────────────────────────────
const formatNumber = (n: number) => n.toLocaleString('zh-CN');
-// 解析批量粘贴的用户属性串:key=value 按换行或逗号分隔,等号只取第一个
-const parsePropsText = (text: string): Record<string, string> => {
- const props: Record<string, string> = {};
- for (const line of text.split(/[\n,]+/)) {
- const trimmed = line.trim();
- if (!trimmed) continue;
- const eqIndex = trimmed.indexOf('=');
- if (eqIndex <= 0) continue;
- const key = trimmed.slice(0, eqIndex).trim();
- if (key) props[key] = trimmed.slice(eqIndex + 1).trim();
- }
- return props;
-};
const formatDateTime = (iso?: string): string => {
if (!iso) return '-';
const d = new Date(iso);
@@ -946,7 +934,12 @@ const TopicPage = () => {
// Build properties: batch-paste text mode or key-value form rows
let props: Record<string, string> = {};
if (propsMode === 'text') {
- props = parsePropsText(values.propsText || '');
+ const parsed = parseMessageProperties(values.propsText || '');
+ if (parsed.errors.length > 0) {
+ message.error(`消息属性格式错误:${parsed.errors.join(';')}`);
+ return;
+ }
+ props = parsed.properties;
} else if (values.properties && Array.isArray(values.properties)) {
values.properties.forEach((p: { key?: string; value?: string }) => {
if (p.key) props[p.key] = p.value || '';
diff --git a/web/src/pages/studio/LiteTopic.tsx
b/web/src/pages/studio/LiteTopic.tsx
index 0035446ab..7692ad95d 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -166,6 +166,9 @@ const LiteTopicPage: React.FC = () => {
if (quotaResult.status === 'fulfilled') {
setQuota(quotaResult.value);
+ } else {
+ setQuota(null);
+
messageRef.current.warning(translationRef.current('liteTopic.fetchQuotaFailed'));
}
if (listResult.status === 'fulfilled') {
diff --git a/web/src/pages/studio/Producer.tsx
b/web/src/pages/studio/Producer.tsx
index 68cff6813..9ac04261a 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -85,6 +85,7 @@ const ProducerPage = () => {
const { message } = App.useApp();
const fetchTopicFailedMessage = t('producer.fetchTopicFailed');
const queryRequestIdRef = useRef(0);
+ const queryInFlightRef = useRef<number | null>(null);
useEffect(() => {
let cancelled = false;
@@ -113,6 +114,7 @@ const ProducerPage = () => {
const handleInstanceChange = (instanceId: string) => {
queryRequestIdRef.current += 1;
+ queryInFlightRef.current = null;
setSelectedInstanceId(instanceId);
setTopicList([]);
setProducerGroups([]);
@@ -178,11 +180,15 @@ const ProducerPage = () => {
}, [selectedInstanceId]);
const onFinish = async (values: { selectedTopic: string; producerGroup:
string }) => {
+ if (queryInFlightRef.current !== null) return;
if (!selectedInstanceId) {
message.error('Select an instance before querying producer
connections.');
return;
}
const requestId = ++queryRequestIdRef.current;
+ queryInFlightRef.current = requestId;
+ setConnectionList([]);
+ setConnectionSummary(null);
setLoading(true);
try {
const result = await queryProducerConnection(
@@ -202,6 +208,9 @@ const ProducerPage = () => {
message.error(t('producer.fetchConnectionFailed'));
}
} finally {
+ if (queryInFlightRef.current === requestId) {
+ queryInFlightRef.current = null;
+ }
if (requestId === queryRequestIdRef.current) {
setLoading(false);
}
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index d2b88af4e..6deaea9ae 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -465,4 +465,25 @@ describe('LiteTopic Page', () => {
expect(screen.queryByText('old-*')).not.toBeInTheDocument();
expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
});
+
+ it('clears stale quota while preserving a successfully refreshed topic
list', async () => {
+ apiMocks.queryLiteTopicQuota
+ .mockResolvedValueOnce(createQuota(90))
+ .mockRejectedValueOnce(new Error('quota unavailable'));
+ apiMocks.queryLiteTopicList
+ .mockResolvedValueOnce([{ namespace: 'default', topicPattern: 'old-*' }])
+ .mockResolvedValueOnce([{ namespace: 'default', topicPattern: 'fresh-*'
}]);
+ const user = userEvent.setup();
+ renderPage();
+
+ expect(await screen.findByText('old-*')).toBeInTheDocument();
+ expect(screen.getByText('90 / 100')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /刷新/ }));
+
+ expect(await screen.findByText('fresh-*')).toBeInTheDocument();
+ expect(screen.queryByText('old-*')).not.toBeInTheDocument();
+ expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+ expect(await screen.findByText('获取配额信息失败')).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx
b/web/src/pages/studio/__tests__/Producer.test.tsx
index 3fabf951f..1167ad269 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -422,4 +422,66 @@ describe('ProducerPage', () => {
expect(within(container).queryByText('producer-1')).not.toBeInTheDocument();
expect(within(container).queryByText('order-events')).not.toBeInTheDocument();
});
+
+ it('clears stale results when a new producer query fails', async () => {
+ const user = userEvent.setup();
+ vi.mocked(queryProducerConnection)
+ .mockResolvedValueOnce(
+ producerResult([
+ {
+ clientId: 'producer-1',
+ clientAddr: '192.168.1.10',
+ language: 'JAVA',
+ versionDesc: '5.1.0',
+ },
+ ]),
+ )
+ .mockRejectedValueOnce(new Error('broker unavailable'));
+ renderWithProviders(<ProducerPage />);
+
+ await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
+ const [, topicSelect, groupInput] = screen.getAllByRole('combobox');
+ fireEvent.mouseDown(topicSelect.parentElement!);
+ await user.click(
+ await screen.findByText('order-events', { selector:
'.ant-select-item-option-content' }),
+ );
+ await user.type(groupInput, 'order-producer');
+ const search = screen.getByRole('button', { name: /搜索/ });
+ await user.click(search);
+ expect(await screen.findByText('producer-1')).toBeInTheDocument();
+
+ await user.click(search);
+
+ await waitFor(() =>
expect(queryProducerConnection).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
expect(screen.queryByText('producer-1')).not.toBeInTheDocument());
+ expect(screen.queryByText('生产者连接健康')).not.toBeInTheDocument();
+ });
+
+ it('ignores duplicate producer queries while the first request is pending',
async () => {
+ const user = userEvent.setup();
+ let resolveQuery: ((value: ProducerConnectionResult) => void) | undefined;
+ vi.mocked(queryProducerConnection).mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveQuery = resolve;
+ }),
+ );
+ renderWithProviders(<ProducerPage />);
+
+ await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
+ const [, topicSelect, groupInput] = screen.getAllByRole('combobox');
+ fireEvent.mouseDown(topicSelect.parentElement!);
+ await user.click(
+ await screen.findByText('order-events', { selector:
'.ant-select-item-option-content' }),
+ );
+ await user.type(groupInput, 'order-producer');
+ const search = screen.getByRole('button', { name: /搜索/ });
+ await user.click(search);
+ await waitFor(() =>
expect(queryProducerConnection).toHaveBeenCalledTimes(1));
+ fireEvent.click(search);
+
+ expect(queryProducerConnection).toHaveBeenCalledTimes(1);
+ resolveQuery?.(producerResult([]));
+ await waitFor(() => expect(search).not.toHaveClass('ant-btn-loading'));
+ });
});
diff --git a/web/src/utils/messageProperties.ts
b/web/src/utils/messageProperties.ts
new file mode 100644
index 000000000..552b0d07c
--- /dev/null
+++ b/web/src/utils/messageProperties.ts
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+interface ParsedProperties {
+ properties: Record<string, string>;
+ errors: string[];
+}
+
+// 解析批量粘贴的用户属性串:key=value 按换行或逗号分隔,等号只取第一个
+export const parseMessageProperties = (text: string): ParsedProperties => {
+ const properties: Record<string, string> = {};
+ const errors: string[] = [];
+ for (const line of text.split(/[\n,]+/)) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ const eqIndex = trimmed.indexOf('=');
+ if (eqIndex <= 0) {
+ errors.push(`“${trimmed}”应使用 key=value 格式`);
+ continue;
+ }
+ const key = trimmed.slice(0, eqIndex).trim();
+ if (!key) {
+ errors.push(`“${trimmed}”的属性名不能为空`);
+ } else if (Object.prototype.hasOwnProperty.call(properties, key)) {
+ errors.push(`属性名“${key}”重复`);
+ } else {
+ properties[key] = trimmed.slice(eqIndex + 1).trim();
+ }
+ }
+ return { properties, errors };
+};
diff --git a/web/src/utils/resourceCsvImport.test.ts
b/web/src/utils/resourceCsvImport.test.ts
index 329d8dfc1..4621e38df 100644
--- a/web/src/utils/resourceCsvImport.test.ts
+++ b/web/src/utils/resourceCsvImport.test.ts
@@ -67,6 +67,23 @@ describe('resourceCsvImport', () => {
});
});
+ it.each(['\t', '\r', '\n'])('restores exported control-prefixed cells for
%j', (prefix) => {
+ const records = parseCsvTable(`"Name","Remark"\n"'${prefix}topic-a","ok"`);
+
+ expect(records[0].values.Name).toBe('topic-a');
+ });
+
+ it('tracks lone carriage returns inside quoted fields when reporting row
errors', () => {
+ const content = [
+ '"Name","Remark"',
+ '"topic-a","line1\rline2"',
+ '"topic-b","ok"',
+ '"topic-c","too","many"',
+ ].join('\r\n');
+
+ expect(() => parseCsvTable(content)).toThrow('第 5 行字段数超过表头字段数');
+ });
+
it('validates topic fields and duplicate names before import calls', () => {
const records = parseCsvTable(
[
diff --git a/web/src/utils/resourceCsvImport.ts
b/web/src/utils/resourceCsvImport.ts
index a94385f48..19a6d10f7 100644
--- a/web/src/utils/resourceCsvImport.ts
+++ b/web/src/utils/resourceCsvImport.ts
@@ -43,7 +43,7 @@ interface ParsedCsvRow {
cells: string[];
}
-const FORMULA_SAFE_PREFIX_PATTERN = /^'(?=[=+\-@])/;
+const FORMULA_SAFE_PREFIX_PATTERN = /^'(?=[=+\-@\t\r\n])/;
const TOPIC_NAME_PATTERN = /^[a-zA-Z0-9_\-/*]+$/;
const GROUP_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
@@ -122,7 +122,7 @@ const readCsvRows = (content: string): ParsedCsvRow[] => {
quoteJustClosed = true;
}
} else {
- if (char === '\n') lineNumber += 1;
+ if (char === '\n' || (char === '\r' && next !== '\n')) lineNumber += 1;
cell += char;
}
continue;