This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 4ad2f66df feat(config-ui): add a Teams page for org data management
(#9205)
4ad2f66df is described below
commit 4ad2f66df042386888cbcd7a0b585c4a2c13f90a
Author: Itai Spiegel <[email protected]>
AuthorDate: Wed Oct 14 09:59:22 2026 +0300
feat(config-ui): add a Teams page for org data management (#9205)
DevLake's team dashboards (DORA by Team, Engineering Throughput - Team
View, Copilot by Team) join through `teams`, `team_users` and
`user_accounts`, but config-ui offered no way to populate them: the org
plugin's four CSV endpoints were reachable only by hand-crafting a
multipart upload. On a fresh install those tables stay empty and every
team dashboard renders blank.
Adds a Teams page with three tabs backed by the existing org endpoints,
so no backend change is required:
- Teams: id, name, alias, sorting index, and parent team as a select of
the other teams rather than a raw id.
- Members: id, name, email, and team membership as a multi-select that
serialises back to the `;`-delimited TeamIds cell.
- Account Mapping: every collected tool account, with its identity
columns read-only and a select to map it to a member. Rows cannot be
added or removed here because accounts come from data collection.
Each tab round-trips a whole CSV file, matching the endpoints' existing
delete-all-then-save semantics; the page says so in an alert, since a
save overwrites rows added elsewhere since the page loaded. Columns the
UI does not edit (AvatarUrl, CreatedDate, Status) are preserved by
keeping the server's header order and writing back untouched cells.
The CSV helpers cover the subset of RFC 4180 that gocsv emits - quoted
delimiters, doubled quotes, embedded newlines, CRLF - and are unit
tested for round-trip stability.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
config-ui/src/api/index.ts | 2 +
config-ui/src/api/org/index.ts | 43 +++++
config-ui/src/app/router.tsx | 5 +
config-ui/src/routes/index.ts | 1 +
config-ui/src/routes/layout/config.tsx | 6 +
config-ui/src/routes/team/editable-table.tsx | 122 +++++++++++++
config-ui/src/{utils => routes/team}/index.ts | 4 +-
config-ui/src/routes/team/team.tsx | 238 ++++++++++++++++++++++++++
config-ui/src/utils/csv.test.ts | 73 ++++++++
config-ui/src/utils/csv.ts | 119 +++++++++++++
config-ui/src/utils/index.ts | 1 +
11 files changed, 611 insertions(+), 3 deletions(-)
diff --git a/config-ui/src/api/index.ts b/config-ui/src/api/index.ts
index 378ba1477..f69c8cb13 100644
--- a/config-ui/src/api/index.ts
+++ b/config-ui/src/api/index.ts
@@ -22,6 +22,7 @@ import * as apiKey from './api-key';
import * as auth from './auth';
import * as blueprint from './blueprint';
import * as connection from './connection';
+import * as org from './org';
import * as pipeline from './pipeline';
import plugin from './plugin';
import * as project from './project';
@@ -39,6 +40,7 @@ export const API = {
auth,
blueprint,
connection,
+ org,
pipeline,
plugin,
project,
diff --git a/config-ui/src/api/org/index.ts b/config-ui/src/api/org/index.ts
new file mode 100644
index 000000000..deb5cc983
--- /dev/null
+++ b/config-ui/src/api/org/index.ts
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ *
+ */
+
+import { request } from '@/utils';
+
+// The org plugin stores teams, users and their account mapping as whole CSV
files:
+// every PUT replaces the entire table (deleteAll + save), so the UI always
sends
+// back the complete set of rows rather than a delta.
+const upload = (path: string, csv: string, filename: string) => {
+ const data = new FormData();
+ data.append('file', new Blob([csv], { type: 'text/csv' }), filename);
+ return request(path, { method: 'put', data });
+};
+
+export const getTeams = (): Promise<string> =>
request('/plugins/org/teams.csv');
+
+export const updateTeams = (csv: string) => upload('/plugins/org/teams.csv',
csv, 'teams.csv');
+
+export const getUsers = (): Promise<string> =>
request('/plugins/org/users.csv');
+
+export const updateUsers = (csv: string) => upload('/plugins/org/users.csv',
csv, 'users.csv');
+
+// Returns every known account (one per connected tool identity) with the
UserId it
+// is currently mapped to, not only the accounts that already have one.
+export const getUserAccountMapping = (): Promise<string> =>
request('/plugins/org/user_account_mapping.csv');
+
+export const updateUserAccountMapping = (csv: string) =>
+ upload('/plugins/org/user_account_mapping.csv', csv,
'user_account_mapping.csv');
diff --git a/config-ui/src/app/router.tsx b/config-ui/src/app/router.tsx
index c9ffe5f23..e7f1a9043 100644
--- a/config-ui/src/app/router.tsx
+++ b/config-ui/src/app/router.tsx
@@ -35,6 +35,7 @@ import {
Pipelines,
Pipeline,
ApiKeys,
+ Team,
NotFound,
} from '@/routes';
@@ -112,6 +113,10 @@ export const router = createBrowserRouter([
},
],
},
+ {
+ path: 'teams',
+ element: <Team />,
+ },
{
path: 'keys',
element: <ApiKeys />,
diff --git a/config-ui/src/routes/index.ts b/config-ui/src/routes/index.ts
index 12e9f77ba..8550e0a0f 100644
--- a/config-ui/src/routes/index.ts
+++ b/config-ui/src/routes/index.ts
@@ -27,3 +27,4 @@ export * from './not-found';
export * from './onboard';
export * from './pipeline';
export * from './project';
+export * from './team';
diff --git a/config-ui/src/routes/layout/config.tsx
b/config-ui/src/routes/layout/config.tsx
index ae6ac5dbf..862d9529a 100644
--- a/config-ui/src/routes/layout/config.tsx
+++ b/config-ui/src/routes/layout/config.tsx
@@ -21,6 +21,7 @@ import {
ProjectOutlined,
ExperimentOutlined,
KeyOutlined,
+ TeamOutlined,
DashboardOutlined,
FileSearchOutlined,
ApiOutlined,
@@ -66,6 +67,11 @@ export const menuItems: MenuItem[] = [
},
],
},
+ {
+ key: `${PATH_PREFIX}/teams`,
+ label: 'Teams',
+ icon: <TeamOutlined />,
+ },
{
key: `${PATH_PREFIX}/keys`,
label: 'API Keys',
diff --git a/config-ui/src/routes/team/editable-table.tsx
b/config-ui/src/routes/team/editable-table.tsx
new file mode 100644
index 000000000..a6316df95
--- /dev/null
+++ b/config-ui/src/routes/team/editable-table.tsx
@@ -0,0 +1,122 @@
+/*
+ * 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.
+ *
+ */
+
+import { DeleteOutlined } from '@ant-design/icons';
+import { Table, Input, Select, Button, Tooltip } from 'antd';
+
+export type EditableRow = Record<string, string> & { _key: string };
+
+export type EditableField = {
+ key: string;
+ title: string;
+ type?: 'text' | 'select' | 'multiple';
+ width?: number;
+ placeholder?: string;
+ options?: Array<{ label: string; value: string }>;
+ // Multi-value fields are stored in the CSV as a single delimited cell.
+ separator?: string;
+ editable?: boolean;
+ invalid?: (row: EditableRow, rows: EditableRow[]) => string | undefined;
+};
+
+interface Props {
+ fields: EditableField[];
+ rows: EditableRow[];
+ onChange: (rows: EditableRow[]) => void;
+ removable?: boolean;
+ emptyText?: string;
+}
+
+export const EditableTable = ({ fields, rows, onChange, removable = true,
emptyText }: Props) => {
+ const update = (key: string, field: string, value: string) =>
+ onChange(rows.map((row) => (row._key === key ? { ...row, [field]: value }
: row)));
+
+ const columns = fields.map((field) => ({
+ title: field.title,
+ dataIndex: field.key,
+ width: field.width,
+ render: (value: string, row: EditableRow) => {
+ if (field.editable === false) {
+ return value || <span style={{ color: '#9ca3af' }}>—</span>;
+ }
+
+ if (field.type === 'select' || field.type === 'multiple') {
+ const separator = field.separator ?? ';';
+ const multiple = field.type === 'multiple';
+ return (
+ <Select
+ style={{ width: '100%' }}
+ mode={multiple ? 'multiple' : undefined}
+ allowClear
+ showSearch
+ optionFilterProp="label"
+ placeholder={field.placeholder}
+ options={field.options}
+ value={multiple ? (value ? value.split(separator).filter(Boolean)
: []) : value || undefined}
+ onChange={(next) =>
+ update(
+ row._key,
+ field.key,
+ multiple ? ((next as string[]) ?? []).join(separator) : ((next
as string) ?? ''),
+ )
+ }
+ />
+ );
+ }
+
+ const error = field.invalid?.(row, rows);
+ return (
+ <Tooltip title={error} open={error ? undefined : false}>
+ <Input
+ status={error ? 'error' : undefined}
+ placeholder={field.placeholder}
+ value={value}
+ onChange={(e) => update(row._key, field.key, e.target.value)}
+ />
+ </Tooltip>
+ );
+ },
+ }));
+
+ if (removable) {
+ columns.push({
+ title: '',
+ dataIndex: '_actions',
+ width: 50,
+ render: (_: string, row: EditableRow) => (
+ <Button
+ type="text"
+ icon={<DeleteOutlined />}
+ onClick={() => onChange(rows.filter((it) => it._key !== row._key))}
+ />
+ ),
+ });
+ }
+
+ return (
+ <Table
+ rowKey="_key"
+ size="small"
+ columns={columns}
+ dataSource={rows}
+ pagination={false}
+ locale={{ emptyText }}
+ scroll={{ x: true }}
+ />
+ );
+};
diff --git a/config-ui/src/utils/index.ts b/config-ui/src/routes/team/index.ts
similarity index 91%
copy from config-ui/src/utils/index.ts
copy to config-ui/src/routes/team/index.ts
index 9b21e33e2..d26db9311 100644
--- a/config-ui/src/utils/index.ts
+++ b/config-ui/src/routes/team/index.ts
@@ -16,6 +16,4 @@
*
*/
-export * from './operator';
-export * from './request';
-export * from './time';
+export * from './team';
diff --git a/config-ui/src/routes/team/team.tsx
b/config-ui/src/routes/team/team.tsx
new file mode 100644
index 000000000..0e9525f92
--- /dev/null
+++ b/config-ui/src/routes/team/team.tsx
@@ -0,0 +1,238 @@
+/*
+ * 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.
+ *
+ */
+
+import { useState, useMemo, useEffect } from 'react';
+import { PlusOutlined } from '@ant-design/icons';
+import { Flex, Tabs, Button, Alert } from 'antd';
+
+import API from '@/api';
+import { PageHeader } from '@/components';
+import { useRefreshData } from '@/hooks';
+import { operator, parseCSV, stringifyCSV } from '@/utils';
+import type { CSVTable } from '@/utils';
+
+import { EditableTable } from './editable-table';
+import type { EditableRow, EditableField } from './editable-table';
+
+type Resource = 'teams' | 'users' | 'accounts';
+
+let keySeed = 0;
+const nextKey = () => `row-${(keySeed += 1)}`;
+
+const withKeys = (table: CSVTable): EditableRow[] =>
+ table.rows.map((row) => ({ ...row, _key: nextKey() }) as EditableRow);
+
+const toCSV = (headers: string[], rows: EditableRow[]) =>
+ stringifyCSV({
+ headers,
+ rows: rows.map(({ _key, ...rest }) => rest),
+ });
+
+const required = (field: string, label: string) => (row: EditableRow) =>
+ row[field]?.trim() ? undefined : `${label} is required`;
+
+const uniqueId = (row: EditableRow, rows: EditableRow[]) => {
+ const id = row.Id?.trim();
+ if (!id) {
+ return 'Id is required';
+ }
+ return rows.filter((it) => it.Id?.trim() === id).length > 1 ? 'Id must be
unique' : undefined;
+};
+
+export const Team = () => {
+ const [tab, setTab] = useState<Resource>('teams');
+ const [operating, setOperating] = useState(false);
+ const [version, setVersion] = useState(1);
+
+ const [teams, setTeams] = useState<EditableRow[]>([]);
+ const [users, setUsers] = useState<EditableRow[]>([]);
+ const [accounts, setAccounts] = useState<EditableRow[]>([]);
+ const [headers, setHeaders] = useState<Record<Resource, string[]>>({
+ teams: ['Id', 'Name', 'Alias', 'ParentId', 'SortingIndex'],
+ users: ['Id', 'Name', 'Email', 'TeamIds'],
+ accounts: [],
+ });
+
+ const { data, ready } = useRefreshData(
+ () =>
+ Promise.all([API.org.getTeams(), API.org.getUsers(),
API.org.getUserAccountMapping()]).then(
+ ([teamsCSV, usersCSV, accountsCSV]) => ({
+ teams: parseCSV(teamsCSV),
+ users: parseCSV(usersCSV),
+ accounts: parseCSV(accountsCSV),
+ }),
+ ),
+ [version],
+ );
+
+ useEffect(() => {
+ if (!data) {
+ return;
+ }
+ setTeams(withKeys(data.teams));
+ setUsers(withKeys(data.users));
+ setAccounts(withKeys(data.accounts));
+ setHeaders({
+ teams: data.teams.headers.length ? data.teams.headers : headers.teams,
+ users: data.users.headers.length ? data.users.headers : headers.users,
+ accounts: data.accounts.headers,
+ });
+ }, [data]);
+
+ const teamOptions = useMemo(
+ () =>
+ teams
+ .filter((team) => team.Id?.trim())
+ .map((team) => ({ label: team.Name?.trim() || team.Id, value:
team.Id.trim() })),
+ [teams],
+ );
+
+ const userOptions = useMemo(
+ () =>
+ users
+ .filter((user) => user.Id?.trim())
+ .map((user) => ({ label: user.Name?.trim() || user.Id, value:
user.Id.trim() })),
+ [users],
+ );
+
+ const teamFields: EditableField[] = [
+ { key: 'Id', title: 'Id', width: 120, placeholder: 'e.g. 1', invalid:
uniqueId },
+ { key: 'Name', title: 'Name', width: 220, placeholder: 'e.g. Platform',
invalid: required('Name', 'Name') },
+ { key: 'Alias', title: 'Alias', width: 140, placeholder: 'e.g. PLT' },
+ {
+ key: 'ParentId',
+ title: 'Parent team',
+ width: 200,
+ type: 'select',
+ placeholder: 'No parent',
+ options: teamOptions,
+ },
+ { key: 'SortingIndex', title: 'Sorting index', width: 130, placeholder:
'0' },
+ ];
+
+ const userFields: EditableField[] = [
+ { key: 'Id', title: 'Id', width: 140, placeholder: 'e.g. 1', invalid:
uniqueId },
+ { key: 'Name', title: 'Name', width: 200, placeholder: 'Full name',
invalid: required('Name', 'Name') },
+ { key: 'Email', title: 'Email', width: 240, placeholder:
'[email protected]' },
+ { key: 'TeamIds', title: 'Teams', type: 'multiple', placeholder: 'Select
teams', options: teamOptions },
+ ];
+
+ const accountFields: EditableField[] = [
+ { key: 'Id', title: 'Account Id', width: 260, editable: false },
+ { key: 'UserName', title: 'Username', width: 160, editable: false },
+ { key: 'Email', title: 'Email', width: 220, editable: false },
+ { key: 'Organization', title: 'Organization', width: 160, editable: false
},
+ { key: 'UserId', title: 'Mapped user', width: 220, type: 'select',
placeholder: 'Unmapped', options: userOptions },
+ ];
+
+ const invalid = useMemo(() => {
+ const rows = tab === 'teams' ? teams : tab === 'users' ? users : [];
+ const fields = tab === 'teams' ? teamFields : tab === 'users' ? userFields
: [];
+ return rows.some((row) => fields.some((field) => field.invalid?.(row,
rows)));
+ }, [tab, teams, users]);
+
+ const handleAdd = () => {
+ if (tab === 'teams') {
+ setTeams([...teams, { _key: nextKey(), Id: '', Name: '', Alias: '',
ParentId: '', SortingIndex: '0' }]);
+ } else if (tab === 'users') {
+ setUsers([...users, { _key: nextKey(), Id: '', Name: '', Email: '',
TeamIds: '' }]);
+ }
+ };
+
+ const handleSave = async () => {
+ const payload = {
+ teams: () => API.org.updateTeams(toCSV(headers.teams, teams)),
+ users: () => API.org.updateUsers(toCSV(headers.users, users)),
+ accounts: () => API.org.updateUserAccountMapping(toCSV(headers.accounts,
accounts)),
+ }[tab];
+
+ const [success] = await operator(payload, {
+ setOperating,
+ formatMessage: () => 'Saved. Re-run the project pipeline to refresh team
metrics.',
+ });
+
+ if (success) {
+ setVersion(version + 1);
+ }
+ };
+
+ return (
+ <PageHeader
+ breadcrumbs={[{ name: 'Teams', path: '/teams' }]}
+ description="Define your teams, their members, and which tool accounts
belong to each member. Team dashboards in Grafana read this data."
+ >
+ <Alert
+ style={{ marginBottom: 16 }}
+ type="info"
+ showIcon
+ message="Saving replaces the entire table"
+ description="The org plugin stores each of these as a whole file, so a
save overwrites every row — including rows added by someone else since this
page loaded."
+ />
+ <Tabs
+ activeKey={tab}
+ onChange={(key) => setTab(key as Resource)}
+ items={[
+ { key: 'teams', label: `Teams (${teams.length})` },
+ { key: 'users', label: `Members (${users.length})` },
+ { key: 'accounts', label: `Account Mapping (${accounts.length})` },
+ ]}
+ />
+ {tab === 'teams' && (
+ <EditableTable
+ fields={teamFields}
+ rows={teams}
+ onChange={setTeams}
+ emptyText={ready ? 'No teams yet. Add one to get started.' :
'Loading…'}
+ />
+ )}
+ {tab === 'users' && (
+ <EditableTable
+ fields={userFields}
+ rows={users}
+ onChange={setUsers}
+ emptyText={ready ? 'No members yet. Add one, then assign them to a
team.' : 'Loading…'}
+ />
+ )}
+ {tab === 'accounts' && (
+ <EditableTable
+ fields={accountFields}
+ rows={accounts}
+ onChange={setAccounts}
+ removable={false}
+ emptyText={
+ ready
+ ? 'No accounts collected yet. Run a data collection first —
accounts come from your connected tools.'
+ : 'Loading…'
+ }
+ />
+ )}
+ <Flex style={{ marginTop: 16 }} justify="space-between">
+ {tab === 'accounts' ? (
+ <span />
+ ) : (
+ <Button icon={<PlusOutlined />} onClick={handleAdd}>
+ Add {tab === 'teams' ? 'team' : 'member'}
+ </Button>
+ )}
+ <Button type="primary" loading={operating} disabled={!ready ||
invalid} onClick={handleSave}>
+ Save changes
+ </Button>
+ </Flex>
+ </PageHeader>
+ );
+};
diff --git a/config-ui/src/utils/csv.test.ts b/config-ui/src/utils/csv.test.ts
new file mode 100644
index 000000000..3afc75d9b
--- /dev/null
+++ b/config-ui/src/utils/csv.test.ts
@@ -0,0 +1,73 @@
+/*
+ * 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.
+ *
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { parseCSV, stringifyCSV } from './csv';
+
+describe('parseCSV', () => {
+ it('parses the org plugin teams.csv shape', () => {
+ const { headers, rows } =
parseCSV('Id,Name,Alias,ParentId,SortingIndex\n1,Platform,PLT,,0\n2,Payments,PAY,1,1\n');
+
+ expect(headers).toEqual(['Id', 'Name', 'Alias', 'ParentId',
'SortingIndex']);
+ expect(rows).toEqual([
+ { Id: '1', Name: 'Platform', Alias: 'PLT', ParentId: '', SortingIndex:
'0' },
+ { Id: '2', Name: 'Payments', Alias: 'PAY', ParentId: '1', SortingIndex:
'1' },
+ ]);
+ });
+
+ it('keeps quoted delimiters, escaped quotes and embedded newlines in one
field', () => {
+ const { rows } = parseCSV('Id,Name\n1,"Platform, Core"\n2,"He said
""hi"""\n3,"two\nlines"\n');
+
+ expect(rows.map((row) => row.Name)).toEqual(['Platform, Core', 'He said
"hi"', 'two\nlines']);
+ });
+
+ it('handles CRLF line endings and a header-only file', () => {
+ expect(parseCSV('Id,Name\r\n1,Platform\r\n').rows).toEqual([{ Id: '1',
Name: 'Platform' }]);
+ expect(parseCSV('Id,Name\n').rows).toEqual([]);
+ });
+
+ it('pads rows that are missing trailing fields', () => {
+ expect(parseCSV('Id,Name,Email\n1,Platform\n').rows).toEqual([{ Id: '1',
Name: 'Platform', Email: '' }]);
+ });
+});
+
+describe('stringifyCSV', () => {
+ it('round-trips a table unchanged', () => {
+ const csv =
'Id,Name,Email,TeamIds\nu1,Itai,[email protected],1;2\nu2,Dana,[email protected],2\n';
+
+ expect(stringifyCSV(parseCSV(csv))).toBe(csv);
+ });
+
+ it('quotes only the fields that need it', () => {
+ const csv = stringifyCSV({
+ headers: ['Id', 'Name'],
+ rows: [
+ { Id: '1', Name: 'Platform, Core' },
+ { Id: '2', Name: 'He said "hi"' },
+ { Id: '3', Name: 'plain' },
+ ],
+ });
+
+ expect(csv).toBe('Id,Name\n1,"Platform, Core"\n2,"He said
""hi"""\n3,plain\n');
+ });
+
+ it('writes an empty cell for a column the row does not carry', () => {
+ expect(stringifyCSV({ headers: ['Id', 'UserId'], rows: [{ Id: 'acc-1' }]
})).toBe('Id,UserId\nacc-1,\n');
+ });
+});
diff --git a/config-ui/src/utils/csv.ts b/config-ui/src/utils/csv.ts
new file mode 100644
index 000000000..986cf405a
--- /dev/null
+++ b/config-ui/src/utils/csv.ts
@@ -0,0 +1,119 @@
+/*
+ * 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.
+ *
+ */
+
+export type CSVTable = {
+ // Kept in the order the server sent them: the org plugin's endpoints
round-trip
+ // whole files, so columns the UI does not edit still have to be written
back.
+ headers: string[];
+ rows: Array<Record<string, string>>;
+};
+
+// parseCSV implements the subset of RFC 4180 that gocsv (used by the org
plugin)
+// emits: double quotes around fields containing a delimiter, quote or newline,
+// with embedded quotes doubled.
+export const parseCSV = (text: string): CSVTable => {
+ const records: string[][] = [];
+ let record: string[] = [];
+ let field = '';
+ let quoted = false;
+ let i = 0;
+
+ const endField = () => {
+ record.push(field);
+ field = '';
+ };
+
+ const endRecord = () => {
+ endField();
+ // A trailing newline must not produce a spurious empty record.
+ if (record.length > 1 || record[0] !== '') {
+ records.push(record);
+ }
+ record = [];
+ };
+
+ while (i < text.length) {
+ const char = text[i];
+
+ if (quoted) {
+ if (char === '"') {
+ if (text[i + 1] === '"') {
+ field += '"';
+ i += 2;
+ continue;
+ }
+ quoted = false;
+ i += 1;
+ continue;
+ }
+ field += char;
+ i += 1;
+ continue;
+ }
+
+ if (char === '"' && field === '') {
+ quoted = true;
+ i += 1;
+ continue;
+ }
+
+ if (char === ',') {
+ endField();
+ i += 1;
+ continue;
+ }
+
+ if (char === '\r' && text[i + 1] === '\n') {
+ endRecord();
+ i += 2;
+ continue;
+ }
+
+ if (char === '\n' || char === '\r') {
+ endRecord();
+ i += 1;
+ continue;
+ }
+
+ field += char;
+ i += 1;
+ }
+
+ if (field !== '' || record.length) {
+ endRecord();
+ }
+
+ const [headers = [], ...body] = records;
+
+ return {
+ headers,
+ rows: body.map((cells) =>
+ headers.reduce<Record<string, string>>((acc, header, index) => {
+ acc[header] = cells[index] ?? '';
+ return acc;
+ }, {}),
+ ),
+ };
+};
+
+const escapeField = (value: string) => (/[",\r\n]/.test(value) ?
`"${value.replace(/"/g, '""')}"` : value);
+
+export const stringifyCSV = ({ headers, rows }: CSVTable): string =>
+ [headers, ...rows.map((row) => headers.map((header) => row[header] ?? ''))]
+ .map((cells) => cells.map((cell) => escapeField(cell)).join(','))
+ .join('\n') + '\n';
diff --git a/config-ui/src/utils/index.ts b/config-ui/src/utils/index.ts
index 9b21e33e2..d65cd8d6b 100644
--- a/config-ui/src/utils/index.ts
+++ b/config-ui/src/utils/index.ts
@@ -16,6 +16,7 @@
*
*/
+export * from './csv';
export * from './operator';
export * from './request';
export * from './time';