Skip to content

Commit 07fd1c2

Browse files
author
Evgenii Davydov
committed
feat(youtrack): add config-ui plugin — connection form, scope picker, transformation widget
1 parent a7e220f commit 07fd1c2

17 files changed

Lines changed: 1280 additions & 2 deletions

File tree

‎config-ui/src/api/plugin/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { request } from '@/utils';
2121
import * as jira from './jira';
2222
import * as tapd from './tapd';
2323
import * as webhook from './webhook';
24+
import * as youtrack from './youtrack';
2425

2526
export const list = (): Promise<[{ plugin: string }]> => request('/plugins');
2627

@@ -29,6 +30,7 @@ export const plugin = {
2930
jira,
3031
tapd,
3132
webhook,
33+
youtrack,
3234
};
3335

3436
export default plugin;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
import { request } from '@/utils';
20+
21+
export type YoutrackBundleValue = {
22+
id: string;
23+
name: string;
24+
isResolved?: boolean;
25+
ordinal?: number;
26+
archived?: boolean;
27+
};
28+
29+
// YoutrackProjectCustomField mirrors one entry of the
30+
// /admin/projects/{id}/customFields response as requested by the widget's
31+
// fields param. `project(shortName)` rides along so the union tables can
32+
// badge projects by the issue-key prefix users recognize (PROJ in PROJ-123).
33+
export type YoutrackProjectCustomField = {
34+
id: string;
35+
$type: string;
36+
field?: {
37+
id: string;
38+
name: string;
39+
fieldType?: {
40+
id: string;
41+
valueType: string;
42+
isMultiValue: boolean;
43+
};
44+
};
45+
bundle?: {
46+
id: string;
47+
values?: YoutrackBundleValue[];
48+
} | null;
49+
project?: {
50+
shortName: string;
51+
} | null;
52+
};
53+
54+
export type YoutrackScopeConfigProjects = {
55+
count: number;
56+
projects?: Array<{
57+
name: string;
58+
blueprintId: ID;
59+
scopes?: Array<{
60+
scopeId: string;
61+
scopeName: string;
62+
}>;
63+
}>;
64+
};
65+
66+
export const customFields = (prefix: string, projectId: string): Promise<YoutrackProjectCustomField[]> =>
67+
request(`${prefix}/admin/projects/${projectId}/customFields`, {
68+
data: {
69+
fields:
70+
'id,$type,field(id,name,fieldType(id,valueType,isMultiValue)),bundle(id,values(id,name,isResolved,ordinal,archived)),project(shortName)',
71+
},
72+
});
73+
74+
// scopeConfigProjects is the singular, un-nested route (Linear's asymmetry):
75+
// it deliberately takes no connectionId.
76+
export const scopeConfigProjects = (scopeConfigId: ID): Promise<YoutrackScopeConfigProjects> =>
77+
request(`/plugins/youtrack/scope-config/${scopeConfigId}/projects`);

‎config-ui/src/plugins/components/connection-form/index.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import { useState, useEffect, useMemo } from 'react';
2020
import { isEqual, pick } from 'lodash';
21-
import { Flex, Alert, Button } from 'antd';
21+
import { Flex, Alert, Button, message } from 'antd';
2222

2323
import API from '@/api';
2424
import { useAppDispatch, useAppSelector } from '@/hooks';
@@ -131,7 +131,7 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => {
131131

132132
const handleTest = async () => {
133133
const isUpdate = type === 'update' && !!connectionId;
134-
await operator(
134+
const [success, res] = await operator(
135135
() =>
136136
isUpdate
137137
? API.connection.test(
@@ -143,8 +143,13 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => {
143143
{
144144
setOperating,
145145
formatMessage: () => 'Test Connection Successfully.',
146+
hideToast: !!pluginConfig.connection.showTestResultMessage,
146147
},
147148
);
149+
150+
if (success && pluginConfig.connection.showTestResultMessage) {
151+
message.success(res?.message || 'Test Connection Successfully.');
152+
}
148153
};
149154

150155
const handleSave = async () => {

‎config-ui/src/plugins/components/scope-config-form/index.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { ArgoCDTransformation } from '@/plugins/register/argocd';
3838
import { GhCopilotTransformation } from '@/plugins/register/gh-copilot';
3939
import { AsanaTransformation } from '@/plugins/register/asana';
4040
import { ClickUpTransformation } from '@/plugins/register/clickup';
41+
import { YoutrackTransformation } from '@/plugins/register/youtrack';
4142
import { DOC_URL } from '@/release';
4243
import { operator } from '@/utils';
4344

@@ -326,6 +327,16 @@ export const ScopeConfigForm = ({
326327
setTransformation={setTransformation}
327328
/>
328329
)}
330+
331+
{plugin === 'youtrack' && (
332+
<YoutrackTransformation
333+
entities={entities}
334+
connectionId={connectionId}
335+
scopeConfigId={scopeConfigId}
336+
transformation={transformation}
337+
setTransformation={setTransformation}
338+
/>
339+
)}
329340
</Form>
330341
</Card>
331342
<Flex justify="flex-end" gap="small">

‎config-ui/src/plugins/register/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { RootlyConfig } from './rootly';
4141
import { SonarQubeConfig } from './sonarqube';
4242
import { TAPDConfig } from './tapd';
4343
import { WebhookConfig } from './webhook';
44+
import { YoutrackConfig } from './youtrack';
4445
import { ZenTaoConfig } from './zentao';
4546
import { OpsgenieConfig } from './opsgenie';
4647
import { TeambitionConfig } from './teambition';
@@ -77,6 +78,7 @@ export const pluginConfigs: IPluginConfig[] = [
7778
TestmoConfig,
7879
ZenTaoConfig,
7980
WebhookConfig,
81+
YoutrackConfig,
8082
OpsgenieConfig,
8183
TeambitionConfig,
8284
].sort((a, b) => a.sort - b.sort);
Lines changed: 20 additions & 0 deletions
Loading
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
import { IPluginConfig } from '@/types';
20+
21+
import Icon from './assets/icon.svg?react';
22+
import { Endpoint } from './connection-fields';
23+
24+
export const YoutrackConfig: IPluginConfig = {
25+
plugin: 'youtrack',
26+
name: 'YouTrack',
27+
icon: ({ color }) => <Icon fill={color} />,
28+
sort: 21,
29+
connection: {
30+
docLink: 'https://www.jetbrains.com/help/youtrack/devportal/youtrack-rest-api.html',
31+
initialValues: {},
32+
// the test API's diagnostics are the point of the test button: the
33+
// success message names the token's login (and warns on a non-perm:
34+
// token); failures (guest fallback, missing /youtrack prefix) already
35+
// surface through the error toast
36+
showTestResultMessage: true,
37+
fields: [
38+
'name',
39+
({ type, initialValues, values, errors, setValues, setErrors }: any) => (
40+
<Endpoint
41+
key="endpoint"
42+
type={type}
43+
initialValues={initialValues}
44+
values={values}
45+
errors={errors}
46+
setValues={setValues}
47+
setErrors={setErrors}
48+
/>
49+
),
50+
{
51+
key: 'token',
52+
label: 'Permanent Token',
53+
subLabel:
54+
'Your YouTrack permanent token — it starts with `perm:` (YouTrack → Profile → Account Security → New Token). The Test button detects a token that silently authenticated as guest.',
55+
},
56+
'proxy',
57+
{
58+
key: 'rateLimitPerHour',
59+
subLabel: 'Maximum number of API requests per hour. Leave blank for the default (10000).',
60+
defaultValue: 10000,
61+
},
62+
],
63+
},
64+
dataScope: {
65+
// the backend serves remote-scopes only (no search-remote-scopes), so
66+
// search is client-side over the loaded projects (jenkins pattern)
67+
localSearch: true,
68+
title: 'Projects',
69+
},
70+
scopeConfig: {
71+
entities: ['TICKET', 'CROSS'],
72+
transformation: {
73+
typeField: '',
74+
stateField: '',
75+
priorityField: '',
76+
assigneeField: '',
77+
storyPointField: '',
78+
dueDateField: '',
79+
typeMappings: {},
80+
statusMappings: {},
81+
},
82+
},
83+
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
21+
import { validateEndpoint } from './endpoint';
22+
23+
describe('validateEndpoint', () => {
24+
it('accepts the InCloud shape with the /youtrack context path', () => {
25+
expect(validateEndpoint('https://example.myjetbrains.com/youtrack/api')).toBe('');
26+
});
27+
28+
it('accepts the youtrack.cloud shape', () => {
29+
expect(validateEndpoint('https://example.youtrack.cloud/api')).toBe('');
30+
});
31+
32+
it('accepts self-hosted shapes, with and without a context path', () => {
33+
expect(validateEndpoint('https://youtrack.example.com/api')).toBe('');
34+
expect(validateEndpoint('https://www.example.com/youtrack/api')).toBe('');
35+
});
36+
37+
it('accepts http for self-hosted', () => {
38+
expect(validateEndpoint('http://youtrack.internal:8080/api')).toBe('');
39+
});
40+
41+
it('accepts a trailing slash (normalized like the backend)', () => {
42+
expect(validateEndpoint('https://example.myjetbrains.com/youtrack/api/')).toBe('');
43+
expect(validateEndpoint('https://example.myjetbrains.com/youtrack/api//')).toBe('');
44+
});
45+
46+
it('rejects an empty endpoint as required', () => {
47+
expect(validateEndpoint('')).toBe('endpoint is required');
48+
});
49+
50+
it('rejects a URL that does not end in /api, with the example in the message', () => {
51+
const message = validateEndpoint('https://example.myjetbrains.com/youtrack');
52+
expect(message).toContain('/api');
53+
expect(message).toContain('https://example.myjetbrains.com/youtrack/api');
54+
});
55+
56+
it('rejects the most common InCloud error — the missing /youtrack prefix surfaces as a non-/api path', () => {
57+
expect(validateEndpoint('https://example.myjetbrains.com')).not.toBe('');
58+
});
59+
60+
it('rejects non-absolute URLs', () => {
61+
expect(validateEndpoint('example.myjetbrains.com/youtrack/api')).not.toBe('');
62+
expect(validateEndpoint('/youtrack/api')).not.toBe('');
63+
});
64+
65+
it('rejects non-http(s) schemes', () => {
66+
expect(validateEndpoint('ftp://example.com/api')).not.toBe('');
67+
});
68+
});

0 commit comments

Comments
 (0)