Merge branch 'dev'
This commit is contained in:
commit
0cdb20be04
76 changed files with 2603 additions and 1166 deletions
|
|
@ -43,7 +43,8 @@
|
|||
"url": "",
|
||||
"dbs": {
|
||||
"users": "",
|
||||
"issues": ""
|
||||
"issues": "",
|
||||
"dashboards": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
63
frontend/package-lock.json
generated
63
frontend/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
|
|
@ -2977,6 +2978,30 @@
|
|||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
||||
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz",
|
||||
"integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.21.0 < 1"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
|
||||
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
|
||||
"version": "5.1.1-v1",
|
||||
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
|
||||
|
|
@ -11945,6 +11970,12 @@
|
|||
"mobx": "^6.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
|
||||
"integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
|
|
@ -15205,6 +15236,11 @@
|
|||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
|
|
@ -19072,6 +19108,22 @@
|
|||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
||||
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
|
||||
},
|
||||
"@monaco-editor/loader": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz",
|
||||
"integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==",
|
||||
"requires": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"@monaco-editor/react": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
|
||||
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
|
||||
"requires": {
|
||||
"@monaco-editor/loader": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"@nicolo-ribaudo/eslint-scope-5-internals": {
|
||||
"version": "5.1.1-v1",
|
||||
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
|
||||
|
|
@ -25596,6 +25648,12 @@
|
|||
"integrity": "sha512-oe82BNgMr408e6DxMDNat8msXQTuyuqzJ97DPupbhchEfjjHyjsmPSwtXHl+nXiW3tybpb/cr5siUClBqKqv+Q==",
|
||||
"requires": {}
|
||||
},
|
||||
"monaco-editor": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
|
||||
"integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==",
|
||||
"peer": true
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
|
|
@ -27772,6 +27830,11 @@
|
|||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="
|
||||
},
|
||||
"state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
|
||||
},
|
||||
"statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
|
|
|
|||
24
frontend/src/.eslintrc.js
Normal file
24
frontend/src/.eslintrc.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
19
frontend/src/dashboard/dashboard-page.tsx
Normal file
19
frontend/src/dashboard/dashboard-page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import * as Store from './dashboard-store';
|
||||
import { Dashboard } from './dashboard';
|
||||
|
||||
export const DashboardPage = (): JSX.Element => {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const store = Store.Dashboard.create({
|
||||
id: id,
|
||||
loaded: false,
|
||||
});
|
||||
Store.DashboardLoadData(store).then(() => {
|
||||
return Store.LoadDataForWidgets(store);
|
||||
});
|
||||
|
||||
return <Dashboard store={store} />;
|
||||
};
|
||||
154
frontend/src/dashboard/dashboard-store.tsx
Normal file
154
frontend/src/dashboard/dashboard-store.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import axios from 'axios';
|
||||
import { Instance, types } from 'mobx-state-tree';
|
||||
|
||||
type _WidgetParams = Record<string, any> | null;
|
||||
|
||||
export const WidgetParams = types.frozen<_WidgetParams>();
|
||||
|
||||
type _DataLoaderParams = Record<string, any> | null;
|
||||
|
||||
export const DataLoaderParams = types.frozen<_DataLoaderParams>();
|
||||
|
||||
export const Widget = types
|
||||
.model({
|
||||
type: types.string,
|
||||
id: types.string,
|
||||
title: types.string,
|
||||
collapsed: types.maybe(types.boolean),
|
||||
visible: types.boolean,
|
||||
widgetParams: types.maybe(WidgetParams),
|
||||
dataLoaderParams: types.maybe(DataLoaderParams),
|
||||
loaded: false,
|
||||
data: types.maybe(types.frozen<any>()),
|
||||
dashboardId: types.maybe(types.string),
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
show: () => {
|
||||
self.visible = false;
|
||||
},
|
||||
hide: () => {
|
||||
self.visible = true;
|
||||
},
|
||||
toggle: () => {
|
||||
self.visible = !self.visible;
|
||||
},
|
||||
setData: (data: any) => {
|
||||
self.loaded = true;
|
||||
self.data = data;
|
||||
},
|
||||
setDashboardId: (dashboardId: string) => {
|
||||
self.dashboardId = dashboardId;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type IWidget = Instance<typeof Widget>;
|
||||
|
||||
export function createWidgetStore(
|
||||
id: string,
|
||||
type: string,
|
||||
title: string,
|
||||
collapsed = false,
|
||||
widgetParams?: _WidgetParams,
|
||||
dataLoaderParams?: _DataLoaderParams,
|
||||
): IWidget {
|
||||
return Widget.create({
|
||||
id: id,
|
||||
type: type,
|
||||
title: title,
|
||||
collapsed: collapsed,
|
||||
visible: !collapsed,
|
||||
widgetParams: widgetParams,
|
||||
dataLoaderParams: dataLoaderParams,
|
||||
});
|
||||
}
|
||||
|
||||
export const Data = types.model({
|
||||
widgets: types.array(Widget),
|
||||
title: types.maybe(types.string),
|
||||
});
|
||||
|
||||
export const Dashboard = types
|
||||
.model({
|
||||
loaded: types.boolean,
|
||||
id: types.string,
|
||||
data: types.maybe(Data),
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
setData: (data: any) => {
|
||||
if (data.widgets) {
|
||||
for (let i = 0; i < data.widgets.length; i++) {
|
||||
const widget = data.widgets[i];
|
||||
widget.visible = !widget.collapsed;
|
||||
}
|
||||
}
|
||||
self.data = data;
|
||||
self.loaded = true;
|
||||
},
|
||||
setWidgetsData: (data: any) => {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const widgetData = data[i]?.data;
|
||||
const widgetId = data[i]?.widgetId;
|
||||
if (!widgetId || !widgetData) continue;
|
||||
|
||||
const widgets = self.data?.widgets;
|
||||
if (!widgets || widgets.length <= 0) return;
|
||||
|
||||
const widget = widgets.find((w) => w.id == widgetId);
|
||||
if (!widget) continue;
|
||||
|
||||
widget.setData(widgetData);
|
||||
}
|
||||
},
|
||||
setWidgetData: (widgetId: string, data: any) => {
|
||||
const widget = self.data?.widgets.find((w) => w.id == widgetId);
|
||||
if (!widget) return;
|
||||
widget.setData(data);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type IDashboard = Instance<typeof Dashboard>;
|
||||
|
||||
export async function DashboardLoadData(store: IDashboard): Promise<void> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}api/dashboard/${store.id}`;
|
||||
const resp = await axios.get(url);
|
||||
if (!resp.data) return;
|
||||
store.setData(resp.data);
|
||||
}
|
||||
|
||||
export async function LoadDataForWidgets(store: IDashboard): Promise<void> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}api/dashboard/${store.id}/load-data`;
|
||||
const resp = await fetch(url);
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
store.setWidgetsData(data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function LoadDataForWidget(
|
||||
store: IDashboard,
|
||||
widgetId: string,
|
||||
): Promise<void> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}api/dashboard/${store.id}/load-data/${widgetId}`;
|
||||
const resp = await fetch(url);
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
store.setWidgetData(widgetId, data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export async function WidgetLoadData(widget: IWidget): Promise<void> {
|
||||
if (!widget.dashboardId) return;
|
||||
const url = `${process.env.REACT_APP_BACKEND}api/dashboard/${widget.dashboardId}/load-data/${widget.id}`;
|
||||
const resp = await fetch(url);
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data) {
|
||||
widget.setData(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
frontend/src/dashboard/dashboard.tsx
Normal file
43
frontend/src/dashboard/dashboard.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as DashboardStoreNs from './dashboard-store';
|
||||
import * as TopRightMenuNs from '../misc-components/top-right-menu';
|
||||
import * as WidgetNs from './widget';
|
||||
import * as EditorNs from './editor';
|
||||
|
||||
export type Props = { store: DashboardStoreNs.IDashboard };
|
||||
|
||||
export const Dashboard = observer((props: Props): JSX.Element => {
|
||||
if (!props.store.loaded) {
|
||||
console.debug('Dashboard - store:', JSON.stringify(props.store)); // DEBUG
|
||||
return <pre>Loading... {JSON.stringify(props.store)}</pre>;
|
||||
}
|
||||
|
||||
const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false });
|
||||
|
||||
const widgets = props.store.data?.widgets.map((widget) => {
|
||||
widget.setDashboardId(props.store.id);
|
||||
return <WidgetNs.Widget key={widget.id} store={widget}></WidgetNs.Widget>;
|
||||
});
|
||||
|
||||
const editorStore = EditorNs.Store.create({ dashboardId: props.store.id });
|
||||
const onEditClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
editorStore.show();
|
||||
};
|
||||
|
||||
const res = (
|
||||
<div>
|
||||
<EditorNs.Editor store={editorStore}></EditorNs.Editor>
|
||||
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
|
||||
<a href="/dashboards">Назад</a>
|
||||
<span>Дашборд - {props.store.data?.title || props.store.id}</span>
|
||||
<button onClick={onEditClick}>edit</button>
|
||||
</TopRightMenuNs.TopRightMenu>
|
||||
{widgets}
|
||||
</div>
|
||||
);
|
||||
|
||||
return res;
|
||||
});
|
||||
23
frontend/src/dashboard/dashboards-list.tsx
Normal file
23
frontend/src/dashboard/dashboards-list.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import * as Store from './dashboards-store';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
export type Props = { store: Store.IList };
|
||||
|
||||
export const DashboardsList = observer((props: Props): JSX.Element => {
|
||||
if (!props.store.loaded) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{props.store.list.map((item) => {
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<a href={`/dashboard/${item.id}`}>{item.title}</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
});
|
||||
51
frontend/src/dashboard/dashboards-page.tsx
Normal file
51
frontend/src/dashboard/dashboards-page.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import { DashboardsList } from './dashboards-list';
|
||||
import * as Store from './dashboards-store';
|
||||
|
||||
export const DashboardsPage = (): JSX.Element => {
|
||||
const store = Store.List.create({ loaded: false, list: [] });
|
||||
Store.ListStoreLoadData(store);
|
||||
|
||||
const onNewDashboardButtonClick = async (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
const createUrl = `${process.env.REACT_APP_BACKEND}api/dashboard`;
|
||||
const createResp = await fetch(createUrl, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!createResp || !createResp.ok) {
|
||||
alert(`Ошибка - Не удалось создать новый дашборд`);
|
||||
return;
|
||||
}
|
||||
const dashboardId = await createResp.text();
|
||||
const dashboardName = prompt(
|
||||
`Dashboard name for dashboardId = ${dashboardId}`,
|
||||
);
|
||||
if (dashboardName) {
|
||||
const modifyUrl = `${process.env.REACT_APP_BACKEND}api/dashboard/${dashboardId}`;
|
||||
const modifyResp = await fetch(modifyUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ widgets: [], title: dashboardName }),
|
||||
});
|
||||
if (!modifyResp || !modifyResp.ok) {
|
||||
alert(`Не удалось выполнить создание дашборда`);
|
||||
} else {
|
||||
alert(`Создан дашборд ${dashboardName}`);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
alert(`Создан анонимный дашборд ${dashboardId}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={onNewDashboardButtonClick}>New dashboard</button>
|
||||
<DashboardsList store={store} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
32
frontend/src/dashboard/dashboards-store.ts
Normal file
32
frontend/src/dashboard/dashboards-store.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import axios from 'axios';
|
||||
import { Instance, types } from 'mobx-state-tree';
|
||||
|
||||
export const Item = types.model({
|
||||
id: types.string,
|
||||
title: types.string,
|
||||
});
|
||||
|
||||
export type IItem = Instance<typeof Item>;
|
||||
|
||||
export const List = types
|
||||
.model({
|
||||
list: types.array(Item),
|
||||
loaded: types.boolean,
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
setList: (data: any) => {
|
||||
self.list = data;
|
||||
self.loaded = true;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type IList = Instance<typeof List>;
|
||||
|
||||
export async function ListStoreLoadData(store: IList): Promise<void> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}api/dashboards`;
|
||||
const resp = await axios.get(url);
|
||||
if (!resp?.data) return;
|
||||
store.setList(resp.data);
|
||||
}
|
||||
27
frontend/src/dashboard/editor.module.css
Normal file
27
frontend/src/dashboard/editor.module.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
.reset {
|
||||
all: initial;
|
||||
}
|
||||
|
||||
.modal {
|
||||
z-index: 1000;
|
||||
position: fixed;
|
||||
display: none;
|
||||
padding-top: 40px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgb(0, 0, 0);
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
height: 80%;
|
||||
overflow: auto;
|
||||
background-color: #fefefe;
|
||||
margin: auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
width: 80%;
|
||||
}
|
||||
122
frontend/src/dashboard/editor.tsx
Normal file
122
frontend/src/dashboard/editor.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Editor as MonacoEditor } from '@monaco-editor/react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Instance, onSnapshot, types } from 'mobx-state-tree';
|
||||
import Css from './editor.module.css';
|
||||
|
||||
export const Store = types
|
||||
.model({
|
||||
loaded: false,
|
||||
dashboardId: '',
|
||||
visible: false,
|
||||
data: '',
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
setData: (data: string) => {
|
||||
self.loaded = true;
|
||||
self.data = data;
|
||||
},
|
||||
show: () => {
|
||||
if (!self.loaded) LoadDashboardToStore(self as any);
|
||||
self.visible = true;
|
||||
},
|
||||
hide: () => {
|
||||
self.visible = false;
|
||||
},
|
||||
toggleVisible: () => {
|
||||
self.visible = !self.visible;
|
||||
},
|
||||
};
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get displayStyle(): React.CSSProperties {
|
||||
return { display: self.visible ? 'block' : 'none' };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type IStore = Instance<typeof Store>;
|
||||
|
||||
export async function LoadDashboard(dashboardId: string): Promise<string> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}api/dashboard/${dashboardId}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp || !resp.ok) return '';
|
||||
const data = await resp.json();
|
||||
const text = JSON.stringify(data, null, ' ');
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function LoadDashboardToStore(store: IStore): Promise<void> {
|
||||
const data = await LoadDashboard(store.dashboardId);
|
||||
if (data) store.setData(data);
|
||||
}
|
||||
|
||||
export async function SaveDashboard(
|
||||
dashboardId: string,
|
||||
data: string,
|
||||
): Promise<void> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}api/dashboard/${dashboardId}`;
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function SaveDashboardFromStore(store: IStore): Promise<void> {
|
||||
await SaveDashboard(store.dashboardId, store.data);
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
store: IStore;
|
||||
};
|
||||
|
||||
export const Editor = observer((props: Props): JSX.Element => {
|
||||
const onCloseClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
props.store.hide();
|
||||
};
|
||||
const onSaveClick = async (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
props.store.setData(editorValue);
|
||||
await SaveDashboardFromStore(props.store);
|
||||
alert('Сохранено');
|
||||
};
|
||||
|
||||
const [editorValue, setEditorValue] = useState(props.store.data);
|
||||
onSnapshot(props.store, (state) => {
|
||||
setEditorValue(state.data);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={Css.reset}>
|
||||
<div
|
||||
className={Css.modal}
|
||||
style={props.store.displayStyle}
|
||||
onClick={onCloseClick}
|
||||
>
|
||||
<div className={Css.modalContent}>
|
||||
<h1>
|
||||
<button onClick={onCloseClick}>close</button>
|
||||
<button onClick={onSaveClick}>save</button>
|
||||
Редактор дашборда
|
||||
</h1>
|
||||
<MonacoEditor
|
||||
height="80%"
|
||||
defaultLanguage="json"
|
||||
defaultValue={editorValue}
|
||||
value={editorValue}
|
||||
onChange={(value) => setEditorValue(value || '')}
|
||||
></MonacoEditor>
|
||||
</div>
|
||||
</div>
|
||||
Editor
|
||||
</div>
|
||||
);
|
||||
});
|
||||
105
frontend/src/dashboard/widget.tsx
Normal file
105
frontend/src/dashboard/widget.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as DashboardStoreNs from './dashboard-store';
|
||||
import * as WidgetFactoryNs from './widgets/widget-factory';
|
||||
|
||||
export type Props = {
|
||||
store: DashboardStoreNs.IWidget;
|
||||
};
|
||||
|
||||
/**
|
||||
* Пример данных передаваемых в виджет:
|
||||
*
|
||||
* {
|
||||
* "type": "kanban_by_tree",
|
||||
* "id": "first",
|
||||
* "title": "Первый виджет",
|
||||
* "dataLoaderParams": {
|
||||
* "rootIssueId": 2,
|
||||
* "groups": {
|
||||
* "fromIssues": [
|
||||
* {
|
||||
* "issueId": 3,
|
||||
* "name": "Тест"
|
||||
* }
|
||||
* ],
|
||||
* "fromIssuesIncluded": false,
|
||||
* "showOthers": true
|
||||
* },
|
||||
* "statuses": [
|
||||
* "New",
|
||||
* "Re-opened",
|
||||
* "In Progress",
|
||||
* "Code Review",
|
||||
* "Resolved",
|
||||
* "Testing",
|
||||
* "Feedback",
|
||||
* "Wait Release",
|
||||
* "Pending",
|
||||
* "Closed",
|
||||
* "Rejected"
|
||||
* ],
|
||||
* "tags": {
|
||||
* "tagsKeyName": "tags",
|
||||
* "styledTagsKeyName": "styledTags",
|
||||
* "styles": {
|
||||
* "supertag": "background-color: rgb(128, 0, 0); color: rgb(255, 255, 255);"
|
||||
* },
|
||||
* "defaultStyle": "background-color: rgb(128, 128, 128);"
|
||||
* },
|
||||
* "priorities": [
|
||||
* {
|
||||
* "rules": [
|
||||
* {
|
||||
* "priorityName": "P1",
|
||||
* "style": "background-color: #DB2228;"
|
||||
* },
|
||||
* {
|
||||
* "priorityName": "P2",
|
||||
* "style": "background-color: #FA5E26;"
|
||||
* },
|
||||
* {
|
||||
* "priorityName": "P3",
|
||||
* "style": "background-color: #FDAF19;"
|
||||
* },
|
||||
* {
|
||||
* "priorityName": "P4",
|
||||
* "style": "background-color: #31A8FF;",
|
||||
* "default": true
|
||||
* },
|
||||
* {
|
||||
* "priorityName": "P5",
|
||||
* "style": "background-color: #FFFFFF; border: 0.5px solid #393838; color: #202020;"
|
||||
* }
|
||||
* ],
|
||||
* "targetKey": "priorityStyle"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
function onWidgetVisibleToggleClick(store: DashboardStoreNs.IWidget): void {
|
||||
if (!store.loaded && store.dashboardId) {
|
||||
DashboardStoreNs.WidgetLoadData(store);
|
||||
}
|
||||
store.toggle();
|
||||
}
|
||||
|
||||
export const Widget = observer((props: Props): JSX.Element => {
|
||||
const display = props.store.visible ? 'block' : 'none';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<button onClick={() => onWidgetVisibleToggleClick(props.store)}>
|
||||
Show/hide
|
||||
</button>
|
||||
<span>Title - {props.store.title}</span>
|
||||
</div>
|
||||
<div style={{ display: display }}>
|
||||
<WidgetFactoryNs.WidgetFactory store={props.store} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
18
frontend/src/dashboard/widgets/issues-list.tsx
Normal file
18
frontend/src/dashboard/widgets/issues-list.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as DashboardStoreNs from '../dashboard-store';
|
||||
import * as IssuesListBoardsNs from '../../issues-list-board/issues-list-boards';
|
||||
import * as KanbanBoardStoreNs from '../../kanban-board/store';
|
||||
import { onSnapshot } from 'mobx-state-tree';
|
||||
|
||||
export type Props = {
|
||||
store: DashboardStoreNs.IWidget;
|
||||
};
|
||||
|
||||
export const IssuesList = observer((props: Props): JSX.Element => {
|
||||
const store = KanbanBoardStoreNs.PageStore.create({ loaded: false });
|
||||
onSnapshot(props.store, (state) => {
|
||||
if (state?.data) store.setData(state?.data);
|
||||
});
|
||||
return <IssuesListBoardsNs.IssuesListBoards store={store} />;
|
||||
});
|
||||
17
frontend/src/dashboard/widgets/kanban.tsx
Normal file
17
frontend/src/dashboard/widgets/kanban.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Instance, onSnapshot } from 'mobx-state-tree';
|
||||
import * as DashboardStoreNs from '../dashboard-store';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import * as KanbanBoardsNs from '../../kanban-board/kanban-boards';
|
||||
import * as KanbanBoardsStoreNs from '../../kanban-board/store';
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof DashboardStoreNs.Widget>;
|
||||
};
|
||||
|
||||
export const Kanban = observer((props: Props): JSX.Element => {
|
||||
const store = KanbanBoardsStoreNs.PageStore.create({ loaded: false });
|
||||
onSnapshot(props.store, (state) => {
|
||||
if (state?.data) store.setData(state?.data);
|
||||
});
|
||||
return <KanbanBoardsNs.KanbanBoards store={store} />;
|
||||
});
|
||||
30
frontend/src/dashboard/widgets/widget-factory.tsx
Normal file
30
frontend/src/dashboard/widgets/widget-factory.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import * as DashboardStoreNs from '../dashboard-store';
|
||||
import { Instance } from 'mobx-state-tree';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import * as KanbanWidgetNs from './kanban';
|
||||
import { DebugInfo } from '../../misc-components/debug-info';
|
||||
import * as IssuesListNs from './issues-list';
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof DashboardStoreNs.Widget>;
|
||||
};
|
||||
|
||||
export const WidgetFactory = observer((props: Props): JSX.Element => {
|
||||
const type = props.store.type;
|
||||
|
||||
if (type.startsWith('kanban_by_')) {
|
||||
return <KanbanWidgetNs.Kanban store={props.store} />;
|
||||
}
|
||||
|
||||
if (type.startsWith('issues_list_')) {
|
||||
return <IssuesListNs.IssuesList store={props.store} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Unknown widget</div>
|
||||
<DebugInfo value={JSON.stringify(props.store, null, ' ')} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -5,12 +5,12 @@ import App from './App';
|
|||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
document.getElementById('root') as HTMLElement,
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import { IBoardStore } from './store';
|
||||
import * as KanbanBoardStoreNs from '../kanban-board/store';
|
||||
import Css from './issues-list-board.module.css';
|
||||
import * as IssuesListCardNs from './issues-list-card';
|
||||
|
||||
export type Props = {
|
||||
store: IBoardStore
|
||||
}
|
||||
store: KanbanBoardStoreNs.IBoardStore;
|
||||
};
|
||||
|
||||
export const IssuesListBoard = observer((props: Props): JSX.Element => {
|
||||
const list: JSX.Element[] = props.store.data.map((issue) => {
|
||||
return (
|
||||
<IssuesListCardNs.IssuesListCard store={issue} key={issue.id}/>
|
||||
const list: JSX.Element[] = [];
|
||||
const data = props.store.data;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const column = data[i];
|
||||
const issues: any[] = column.issues;
|
||||
for (let j = 0; j < issues.length; j++) {
|
||||
const issue = issues[j];
|
||||
list.push(
|
||||
<IssuesListCardNs.IssuesListCard store={issue} key={issue.id} />,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
let title: JSX.Element;
|
||||
if (props.store.metainfo.url) {
|
||||
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
|
||||
|
|
@ -23,14 +30,18 @@ export const IssuesListBoard = observer((props: Props): JSX.Element => {
|
|||
return (
|
||||
<div className={Css.board}>
|
||||
<div className={Css.boardName}>
|
||||
<h2 className={Css.boardHeader} id={props.store.metainfo.title}>{title}</h2>
|
||||
<h2 className={Css.boardHeader} id={props.store.metainfo.title}>
|
||||
{title}
|
||||
</h2>
|
||||
<a href={`#${props.store.metainfo.title}`}>
|
||||
<img src="/images/anchor BLUE.svg" alt="anchor" className={Css.anchorIcon} />
|
||||
<img
|
||||
src="/images/anchor BLUE.svg"
|
||||
alt="anchor"
|
||||
className={Css.anchorIcon}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div className={Css.listContainer}>
|
||||
{list}
|
||||
</div>
|
||||
<div className={Css.listContainer}>{list}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import * as IssuesListStoreNs from './store';
|
||||
import * as KanbanBoardStoreNs from '../kanban-board/store';
|
||||
import * as IssuesListBoardsNs from './issues-list-boards';
|
||||
|
||||
export const IssuesListBoardPage = (): JSX.Element => {
|
||||
|
|
@ -15,10 +15,12 @@ export const IssuesListBoardPage = (): JSX.Element => {
|
|||
});
|
||||
// DEBUG: end
|
||||
|
||||
const store = IssuesListStoreNs.PageStore.create({loaded: false, type: type, name: name});
|
||||
IssuesListStoreNs.PageStoreLoadData(store);
|
||||
const store = KanbanBoardStoreNs.PageStore.create({
|
||||
loaded: false,
|
||||
type: type,
|
||||
name: name,
|
||||
});
|
||||
KanbanBoardStoreNs.PageStoreLoadData(store);
|
||||
|
||||
return (
|
||||
<IssuesListBoardsNs.IssuesListBoards store={store}/>
|
||||
);
|
||||
return <IssuesListBoardsNs.IssuesListBoards store={store} />;
|
||||
};
|
||||
|
|
@ -1,32 +1,34 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as IssuesListBoardStore from './store';
|
||||
import * as KanbanBoardStoreNs from '../kanban-board/store';
|
||||
import * as IssuesListBoardNs from './issues-list-board';
|
||||
import * as TopRightMenuNs from '../misc-components/top-right-menu';
|
||||
import { SetIssuesReadingTimestamp } from '../utils/unreaded-provider';
|
||||
import * as ServiceActionsButtons from '../utils/service-actions-buttons';
|
||||
|
||||
export type Props = {
|
||||
store: IssuesListBoardStore.IPageStore
|
||||
store: KanbanBoardStoreNs.IPageStore;
|
||||
};
|
||||
|
||||
export const IssuesListBoards = observer((props: Props): JSX.Element => {
|
||||
const data = props.store.data;
|
||||
if (!props.store.loaded || !data) {
|
||||
return <div>Loading...</div>
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const boardData = data[i];
|
||||
const key = boardData.metainfo.title;
|
||||
const board = <IssuesListBoardNs.IssuesListBoard store={boardData} key={key}/>
|
||||
const board = (
|
||||
<IssuesListBoardNs.IssuesListBoard store={boardData} key={key} />
|
||||
);
|
||||
list.push(board);
|
||||
}
|
||||
const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false });
|
||||
const onAllReadItemClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
SetIssuesReadingTimestamp(props.store.issueIds);
|
||||
IssuesListBoardStore.PageStoreLoadData(props.store);
|
||||
KanbanBoardStoreNs.PageStoreLoadData(props.store);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { SpentHoursToFixed } from '../utils/spent-hours-to-fixed';
|
|||
import { getStyleObjectFromString } from '../utils/style';
|
||||
|
||||
export type Props = {
|
||||
store: IIssueStore
|
||||
store: IIssueStore;
|
||||
};
|
||||
|
||||
export const defaultPriorityStyleKey = 'priorityStyle';
|
||||
|
|
@ -21,15 +21,28 @@ export const IssuesListCard = observer((props: Props): JSX.Element => {
|
|||
const detailsStore = IssueDetailsDialogNs.Store.create({
|
||||
issue: props.store,
|
||||
visible: false,
|
||||
unreadedFlagStore: unreadedStore
|
||||
unreadedFlagStore: unreadedStore,
|
||||
});
|
||||
const priorityStyle = getStyleObjectFromString(props.store[defaultPriorityStyleKey]);
|
||||
const tagsNewLine = (props.store.styledTags && props.store.styledTags.length > 0) ? <br/> : null;
|
||||
const priorityStyle = getStyleObjectFromString(
|
||||
props.store[defaultPriorityStyleKey],
|
||||
);
|
||||
const tagsNewLine =
|
||||
props.store.styledTags && props.store.styledTags.length > 0 ? <br /> : null;
|
||||
return (
|
||||
<div className={Css.todoBlock} onClick={(e) => { e.stopPropagation(); detailsStore.show(); }}>
|
||||
<div
|
||||
className={Css.todoBlock}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
detailsStore.show();
|
||||
}}
|
||||
>
|
||||
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
|
||||
<div className={Css.relevanceColor}>
|
||||
<TimePassedNs.TimePassed params={{ fromIssue: { issue: props.store, keyName: 'timePassedClass' } }} />
|
||||
<TimePassedNs.TimePassed
|
||||
params={{
|
||||
fromIssue: { issue: props.store, keyName: 'timePassedClass' },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={Css.importantInformation}>
|
||||
<span className={Css.issueSubject}>
|
||||
|
|
@ -41,12 +54,18 @@ export const IssuesListCard = observer((props: Props): JSX.Element => {
|
|||
/>
|
||||
</span>
|
||||
<span> </span>
|
||||
<span className={Css.timeBox}>{SpentHoursToFixed(props.store.total_spent_hours)} / {SpentHoursToFixed(props.store.total_estimated_hours)}</span>
|
||||
<span className={Css.timeBox}>
|
||||
{SpentHoursToFixed(props.store.total_spent_hours)} /{' '}
|
||||
{SpentHoursToFixed(props.store.total_estimated_hours)}
|
||||
</span>
|
||||
{tagsNewLine}
|
||||
<TagsNs.Tags params={{ tags: props.store.styledTags }} />
|
||||
<div className={Css.positionInfo}>
|
||||
<span className={Css.timeBox}>{props.store.status.name}</span><span> </span>
|
||||
<span className={Css.priorityBox} style={priorityStyle}>{props.store.priority.name}</span>
|
||||
<span className={Css.timeBox}>{props.store.status.name}</span>
|
||||
<span> </span>
|
||||
<span className={Css.priorityBox} style={priorityStyle}>
|
||||
{props.store?.priority?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,49 @@
|
|||
import { Instance, types } from "mobx-state-tree";
|
||||
import { RedmineTypes } from "../redmine-types";
|
||||
import axios from "axios";
|
||||
import { Instance, types } from 'mobx-state-tree';
|
||||
import { RedmineTypes } from '../redmine-types';
|
||||
import axios from 'axios';
|
||||
|
||||
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
|
||||
|
||||
export interface IIssueStore extends Instance<typeof IssueStore> {}
|
||||
export type IIssueStore = Instance<typeof IssueStore>;
|
||||
|
||||
export const MetaInfoStore = types.model({
|
||||
title: types.string,
|
||||
url: types.maybe(types.string),
|
||||
rootIssue: types.maybe(types.model({
|
||||
rootIssue: types.maybe(
|
||||
types.model({
|
||||
id: 0,
|
||||
tracker: types.model({
|
||||
id: 0,
|
||||
name: ''
|
||||
name: '',
|
||||
}),
|
||||
subject: ''
|
||||
}))
|
||||
subject: '',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const BoardStore = types.model({
|
||||
data: types.array(IssueStore),
|
||||
metainfo: MetaInfoStore
|
||||
metainfo: MetaInfoStore,
|
||||
});
|
||||
|
||||
export interface IBoardStore extends Instance<typeof BoardStore> {}
|
||||
export type IBoardStore = Instance<typeof BoardStore>;
|
||||
|
||||
export const PageStore = types.model({
|
||||
loaded: types.boolean,
|
||||
type: types.string,
|
||||
name: types.string,
|
||||
data: types.maybeNull(
|
||||
types.array(BoardStore)
|
||||
)
|
||||
}).actions((self) => {
|
||||
export const PageStore = types
|
||||
.model({
|
||||
loaded: false,
|
||||
type: '',
|
||||
name: '',
|
||||
data: types.maybeNull(types.array(BoardStore)),
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
setData: (data: any) => {
|
||||
self.data = data;
|
||||
self.loaded = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
}).views((self) => {
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get issueIds(): number[] {
|
||||
if (!self.data) return [];
|
||||
|
|
@ -56,23 +59,27 @@ export const PageStore = types.model({
|
|||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
|
||||
const resp = await axios.get(url);
|
||||
if (!(resp?.data)) return;
|
||||
if (!resp?.data) return;
|
||||
|
||||
const data = [];
|
||||
for (let i = 0; i < resp.data.length; i++) {
|
||||
const item = resp.data[i] as {data: any[], metainfo: Record<string, any>};
|
||||
const item = resp.data[i] as { data: any[]; metainfo: Record<string, any> };
|
||||
data.push({
|
||||
metainfo: item.metainfo,
|
||||
data: item.data ? item.data.map((group: { status: string, count: number, issues: any[] }) => {
|
||||
return group.issues
|
||||
}).flat() : []
|
||||
data: item.data
|
||||
? item.data
|
||||
.map((group: { status: string; count: number; issues: any[] }) => {
|
||||
return group.issues;
|
||||
})
|
||||
.flat()
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -83,4 +90,4 @@ export async function PageStoreLoadData(store: IPageStore): Promise<void> {
|
|||
store.setData(data);
|
||||
}
|
||||
|
||||
export interface IPageStore extends Instance<typeof PageStore> {}
|
||||
export type IPageStore = Instance<typeof PageStore>;
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ import { observer } from 'mobx-react-lite';
|
|||
import * as KanbanCard from './kanban-card';
|
||||
|
||||
export type Props = {
|
||||
store: Stores.IColumnStore
|
||||
}
|
||||
store: Stores.IColumnStore;
|
||||
};
|
||||
|
||||
export const Column = observer((props: Props) => {
|
||||
const cards = props.store.cards.map((card) => {
|
||||
return (
|
||||
<KanbanCard.KanbanCard store={card} key={card.issue.id}></KanbanCard.KanbanCard>
|
||||
<KanbanCard.KanbanCard
|
||||
store={card}
|
||||
key={card.issue.id}
|
||||
></KanbanCard.KanbanCard>
|
||||
);
|
||||
});
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
|
|||
import Column from './column';
|
||||
|
||||
export type Props = {
|
||||
store: IBoardStore
|
||||
store: IBoardStore;
|
||||
};
|
||||
|
||||
export const KanbanBoard = observer((props: Props) => {
|
||||
|
|
@ -18,14 +18,14 @@ export const KanbanBoard = observer((props: Props) => {
|
|||
const columns = [];
|
||||
for (let i = 0; i < props.store.data.length; i++) {
|
||||
const column = props.store.data[i];
|
||||
columns.push(<Column store={column}/>)
|
||||
columns.push(<Column store={column} />);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 id={props.store.metainfo.title}>{title} <a href={`#${props.store.metainfo.title}`}>#</a></h1>
|
||||
<div className={KanbanBoardCss.kanbanContainer}>
|
||||
{columns}
|
||||
</div>
|
||||
<h1 id={props.store.metainfo.title}>
|
||||
{title} <a href={`#${props.store.metainfo.title}`}>#</a>
|
||||
</h1>
|
||||
<div className={KanbanBoardCss.kanbanContainer}>{columns}</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,8 +15,13 @@ export const KanbanBoardsPage = (): JSX.Element => {
|
|||
});
|
||||
// DEBUG: end
|
||||
|
||||
const store = Stores.PageStore.create({loaded: false, type: type, name: name, data: null});
|
||||
const store = Stores.PageStore.create({
|
||||
loaded: false,
|
||||
type: type,
|
||||
name: name,
|
||||
data: null,
|
||||
});
|
||||
Stores.PageStoreLoadData(store);
|
||||
|
||||
return <KBS.KanbanBoards store={store} />;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import axios from 'axios';
|
|||
import * as ServiceActionsButtons from '../utils/service-actions-buttons';
|
||||
|
||||
export type Props = {
|
||||
store: IPageStore
|
||||
}
|
||||
store: IPageStore;
|
||||
};
|
||||
|
||||
export const KanbanBoards = observer((props: Props) => {
|
||||
const data = props.store.data;
|
||||
if (!props.store.loaded || !data) {
|
||||
return <div>Loading...</div>
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
|
|
@ -34,9 +34,13 @@ export const KanbanBoards = observer((props: Props) => {
|
|||
const onTreeRefreshClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
axios.get(`${process.env.REACT_APP_BACKEND}simple-kanban-board/tree/${props.store.name}/refresh`);
|
||||
}
|
||||
treeRefreshMenuItem = <button onClick={onTreeRefreshClick}>Force tree refresh</button>;
|
||||
axios.get(
|
||||
`${process.env.REACT_APP_BACKEND}simple-kanban-board/tree/${props.store.name}/refresh`,
|
||||
);
|
||||
};
|
||||
treeRefreshMenuItem = (
|
||||
<button onClick={onTreeRefreshClick}>Force tree refresh</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import * as IssueDetailsDialogNs from '../misc-components/issue-details-dialog';
|
|||
import * as UnreadedFlagNs from '../misc-components/unreaded-flag';
|
||||
|
||||
export type Props = {
|
||||
store: ICardStore
|
||||
store: ICardStore;
|
||||
};
|
||||
|
||||
export type TagProps = {
|
||||
|
|
@ -24,7 +24,7 @@ export const KanbanCardTag = (props: TagProps): JSX.Element => {
|
|||
{props.tag}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Какие дальше требования к карточкам?
|
||||
|
|
@ -43,33 +43,47 @@ export const KanbanCard = observer((props: Props) => {
|
|||
if (tagsParams && props.store.issue[tagsParams.path]) {
|
||||
const tags = props.store.issue[tagsParams.path] as TagProps[];
|
||||
console.debug(`Tags:`, tags); // DEBUG
|
||||
tagsSection = <TagsNs.Tags params={{tags: tags}}/>
|
||||
tagsSection = <TagsNs.Tags params={{ tags: tags }} />;
|
||||
}
|
||||
const timePassedParams: TimePassedNs.Params = {
|
||||
fromIssue: {
|
||||
issue: props.store.issue,
|
||||
keyName: 'timePassedClass'
|
||||
}
|
||||
}
|
||||
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store.issue);
|
||||
keyName: 'timePassedClass',
|
||||
},
|
||||
};
|
||||
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(
|
||||
props.store.issue,
|
||||
);
|
||||
const detailsStore = IssueDetailsDialogNs.Store.create({
|
||||
issue: props.store.issue,
|
||||
visible: false,
|
||||
unreadedFlagStore: unreadedStore
|
||||
unreadedFlagStore: unreadedStore,
|
||||
});
|
||||
return (
|
||||
<div className={KanbanCardCss.kanbanCard} onClick={(e) => {e.stopPropagation(); detailsStore.show();}}>
|
||||
<div
|
||||
className={KanbanCardCss.kanbanCard}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
detailsStore.show();
|
||||
}}
|
||||
>
|
||||
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
|
||||
<div className={KanbanCardCss.kanbanCardTitle}>
|
||||
<UnreadedFlagNs.UnreadedFlag store={unreadedStore} />
|
||||
<TimePassedNs.TimePassed params={timePassedParams} />
|
||||
<a href={props.store.issue.url.url}>{props.store.issue.tracker.name} #{props.store.issue.id} - {props.store.issue.subject}</a>
|
||||
<a href={props.store.issue.url.url}>
|
||||
{props.store.issue.tracker.name} #{props.store.issue.id} -{' '}
|
||||
{props.store.issue.subject}
|
||||
</a>
|
||||
</div>
|
||||
<div>Исп.: {props.store.issue.current_user.name}</div>
|
||||
<div>Прио.: {props.store.issue.priority.name}</div>
|
||||
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div>
|
||||
<div>Прогресс: {props.store.issue.done_ratio}</div>
|
||||
<div>Трудозатраты: {props.store.issue.total_spent_hours} / {props.store.issue.total_estimated_hours}</div>
|
||||
<div>
|
||||
Трудозатраты: {props.store.issue.total_spent_hours} /{' '}
|
||||
{props.store.issue.total_estimated_hours}
|
||||
</div>
|
||||
{tagsSection}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,65 +2,71 @@ import { Instance, types } from 'mobx-state-tree';
|
|||
import { RedmineTypes } from '../redmine-types';
|
||||
import axios from 'axios';
|
||||
|
||||
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>()
|
||||
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
|
||||
|
||||
export interface IIssueStore extends Instance<typeof IssueStore> {}
|
||||
export type IIssueStore = Instance<typeof IssueStore>;
|
||||
|
||||
export const ColumnStore = types.model({
|
||||
export const ColumnStore = types
|
||||
.model({
|
||||
status: '',
|
||||
count: 0,
|
||||
issues: types.array(IssueStore)
|
||||
}).views((self) => {
|
||||
issues: types.array(IssueStore),
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get cards(): ICardStore[] {
|
||||
return self.issues.map(issue => {
|
||||
return self.issues.map((issue) => {
|
||||
return CardStore.create({
|
||||
issue: issue
|
||||
})
|
||||
issue: issue,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export interface IColumnStore extends Instance<typeof ColumnStore> {}
|
||||
export type IColumnStore = Instance<typeof ColumnStore>;
|
||||
|
||||
export const MetaInfoStore = types.model({
|
||||
title: '',
|
||||
url: types.maybe(types.string),
|
||||
rootIssue: types.maybe(types.model({
|
||||
rootIssue: types.maybe(
|
||||
types.model({
|
||||
id: 0,
|
||||
tracker: types.model({
|
||||
id: 0,
|
||||
name: ''
|
||||
name: '',
|
||||
}),
|
||||
subject: ''
|
||||
}))
|
||||
subject: '',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {}
|
||||
export type IMetaInfoStore = Instance<typeof MetaInfoStore>;
|
||||
|
||||
export const BoardStore = types.model({
|
||||
data: types.array(ColumnStore),
|
||||
metainfo: MetaInfoStore
|
||||
metainfo: MetaInfoStore,
|
||||
});
|
||||
|
||||
export interface IBoardStore extends Instance<typeof BoardStore> {}
|
||||
export type IBoardStore = Instance<typeof BoardStore>;
|
||||
|
||||
export const PageStore = types.model({
|
||||
export const PageStore = types
|
||||
.model({
|
||||
loaded: false,
|
||||
type: '',
|
||||
name: '',
|
||||
data: types.maybeNull(
|
||||
types.array(BoardStore)
|
||||
)
|
||||
}).actions(self => {
|
||||
data: types.maybeNull(types.array(BoardStore)),
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
setData: (data: any) => {
|
||||
console.debug('Kanban page store new data -', data); // DEBUG
|
||||
self.data = data;
|
||||
self.loaded = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
}).views((self) => {
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get issueIds(): number[] {
|
||||
if (!self.data) return [];
|
||||
|
|
@ -80,19 +86,19 @@ export const PageStore = types.model({
|
|||
return res;
|
||||
},
|
||||
get canTreeRefresh(): boolean {
|
||||
return (self.type === 'tree');
|
||||
}
|
||||
return self.type === 'tree';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
|
||||
const resp = await axios.get(url);
|
||||
if (!(resp?.data)) return;
|
||||
if (!resp?.data) return;
|
||||
store.setData(resp.data);
|
||||
}
|
||||
|
||||
export interface IPageStore extends Instance<typeof PageStore> { }
|
||||
export type IPageStore = Instance<typeof PageStore>;
|
||||
|
||||
export type CardField = {
|
||||
component: string;
|
||||
|
|
@ -100,10 +106,8 @@ export type CardField = {
|
|||
|
||||
export const CardParamsStore = types.optional(
|
||||
types.model({
|
||||
fields: types.array(
|
||||
types.frozen<CardField>()
|
||||
),
|
||||
autoCollapse: types.boolean
|
||||
fields: types.array(types.frozen<CardField>()),
|
||||
autoCollapse: types.boolean,
|
||||
}),
|
||||
{
|
||||
fields: [
|
||||
|
|
@ -112,16 +116,15 @@ export const CardParamsStore = types.optional(
|
|||
{ component: 'text', label: 'Версия', path: 'fixed_version.name' },
|
||||
{ component: 'text', label: 'Прогресс', path: 'done_ratio' },
|
||||
{ component: 'labor_costs' },
|
||||
{ component: 'tags', label: 'Tags', path: 'styledTags' }
|
||||
{ component: 'tags', label: 'Tags', path: 'styledTags' },
|
||||
],
|
||||
autoCollapse: false,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const CardStore = types.model({
|
||||
issue: IssueStore,
|
||||
params: CardParamsStore
|
||||
params: CardParamsStore,
|
||||
});
|
||||
|
||||
export interface ICardStore extends Instance<typeof CardStore> {}
|
||||
|
||||
export type ICardStore = Instance<typeof CardStore>;
|
||||
|
|
|
|||
5
frontend/src/misc-components/debug-info.module.css
Normal file
5
frontend/src/misc-components/debug-info.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.debugInfo {
|
||||
margin: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
19
frontend/src/misc-components/debug-info.tsx
Normal file
19
frontend/src/misc-components/debug-info.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import Css from './debug-info.module.css';
|
||||
|
||||
export type Props = {
|
||||
value?: string;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const DebugInfo = (props: Props): JSX.Element => {
|
||||
let output: any;
|
||||
if (props.value) {
|
||||
output = <pre>{props.value}</pre>;
|
||||
} else if (props.children) {
|
||||
output = props.children;
|
||||
} else {
|
||||
output = <pre>(none)</pre>;
|
||||
}
|
||||
return <div className={Css.debugInfo}>{output}</div>;
|
||||
};
|
||||
|
|
@ -9,11 +9,13 @@ import { SetIssueReadingTimestamp } from '../utils/unreaded-provider';
|
|||
import axios from 'axios';
|
||||
import * as Luxon from 'luxon';
|
||||
|
||||
export const Store = types.model({
|
||||
export const Store = types
|
||||
.model({
|
||||
visible: types.boolean,
|
||||
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
|
||||
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store)
|
||||
}).actions((self) => {
|
||||
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store),
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
hide: () => {
|
||||
console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG
|
||||
|
|
@ -27,18 +29,19 @@ export const Store = types.model({
|
|||
} else {
|
||||
SetIssueReadingTimestamp(self.issue.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}).views((self) => {
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get displayStyle(): React.CSSProperties {
|
||||
return { display: self.visible ? 'block' : 'none' };
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof Store>
|
||||
store: Instance<typeof Store>;
|
||||
};
|
||||
|
||||
export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
|
||||
|
|
@ -53,7 +56,11 @@ export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
|
|||
};
|
||||
return (
|
||||
<div className={Css.reset}>
|
||||
<div className={Css.modal} style={props.store.displayStyle} onClick={onCloseClick}>
|
||||
<div
|
||||
className={Css.modal}
|
||||
style={props.store.displayStyle}
|
||||
onClick={onCloseClick}
|
||||
>
|
||||
<div className={Css.modalContent}>
|
||||
<h1>
|
||||
<button onClick={onCloseClick}>close</button>
|
||||
|
|
@ -68,14 +75,15 @@ export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
|
|||
<hr />
|
||||
<div>
|
||||
<h2>Описание:</h2>
|
||||
<pre>
|
||||
{props.store.issue.description}
|
||||
</pre>
|
||||
<pre>{props.store.issue.description}</pre>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<h2>Комментарии:</h2>
|
||||
<Comments details={props.store.issue.journals || []} issue={props.store.issue}/>
|
||||
<Comments
|
||||
details={props.store.issue.journals || []}
|
||||
issue={props.store.issue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -83,33 +91,36 @@ export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
|
|||
);
|
||||
});
|
||||
|
||||
export const Comments = (props: {details?: RedmineTypes.Journal[], issue: RedmineTypes.ExtendedIssue}): JSX.Element => {
|
||||
export const Comments = (props: {
|
||||
details?: RedmineTypes.Journal[];
|
||||
issue: RedmineTypes.ExtendedIssue;
|
||||
}): JSX.Element => {
|
||||
const comments = props.details?.filter((detail) => {
|
||||
return Boolean(detail.notes);
|
||||
});
|
||||
if (!comments) {
|
||||
return <>No comments</>
|
||||
return <>No comments</>;
|
||||
}
|
||||
const list = comments.map((detail) => {
|
||||
const key = `issueid_${props.issue.id}_commentid_${detail.id}`;
|
||||
return <Comment data={detail} key={key}/>
|
||||
return <Comment data={detail} key={key} />;
|
||||
});
|
||||
return (
|
||||
<>{list}</>
|
||||
);
|
||||
}
|
||||
return <>{list}</>;
|
||||
};
|
||||
|
||||
export const Comment = (props: { data: RedmineTypes.Journal }): JSX.Element => {
|
||||
const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat("dd.MM.yyyy HH:mm");
|
||||
const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat(
|
||||
'dd.MM.yyyy HH:mm',
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<h3><span className={Css.dateField}>{date}</span> {props.data.user.name}:</h3>
|
||||
<h3>
|
||||
<span className={Css.dateField}>{date}</span> {props.data.user.name}:
|
||||
</h3>
|
||||
<div>
|
||||
<pre>
|
||||
{props.data.notes || '-'}
|
||||
</pre>
|
||||
<pre>{props.data.notes || '-'}</pre>
|
||||
</div>
|
||||
<hr />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export type Props = {
|
|||
|
||||
export const IssueHref = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<a href={props.url}>{props.tracker} #{props.id} - {props.subject}</a>
|
||||
<a href={props.url}>
|
||||
{props.tracker} #{props.id} - {props.subject}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
@ -17,4 +17,4 @@ export const Tag = (props: Props): JSX.Element => {
|
|||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,21 +7,23 @@ export type Params = {
|
|||
};
|
||||
|
||||
export type Props = {
|
||||
params: Params
|
||||
params: Params;
|
||||
};
|
||||
|
||||
export const Tags = (props: Props): JSX.Element => {
|
||||
if (!props.params.tags) {
|
||||
return (<></>);
|
||||
return <></>;
|
||||
}
|
||||
let label = props.params.label || '';
|
||||
if (label) label = `${label}: `;
|
||||
const tags = props.params.tags.map((tag) => {
|
||||
const tags =
|
||||
props.params.tags.map((tag) => {
|
||||
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag} />;
|
||||
}) || [];
|
||||
return (
|
||||
<>
|
||||
{label}{tags}
|
||||
{label}
|
||||
{tags}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { RedmineTypes } from '../redmine-types';
|
|||
|
||||
export type Params = {
|
||||
fromIssue?: {
|
||||
issue: RedmineTypes.ExtendedIssue,
|
||||
keyName: string,
|
||||
},
|
||||
fromValue?: string
|
||||
issue: RedmineTypes.ExtendedIssue;
|
||||
keyName: string;
|
||||
};
|
||||
fromValue?: string;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
params: Params
|
||||
params: Params;
|
||||
};
|
||||
|
||||
export const TimePassed = (props: Props): JSX.Element => {
|
||||
|
|
@ -21,13 +21,15 @@ export const TimePassed = (props: Props): JSX.Element => {
|
|||
let timePassedClassName = ''; // TODO
|
||||
if (props.params.fromIssue) {
|
||||
const { issue, keyName } = props.params.fromIssue;
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(issue[keyName])}`;
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(
|
||||
issue[keyName],
|
||||
)}`;
|
||||
} else if (props.params.fromValue) {
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(props.params.fromValue)}`;
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(
|
||||
props.params.fromValue,
|
||||
)}`;
|
||||
}
|
||||
return (
|
||||
<span className={timePassedClassName}></span>
|
||||
);
|
||||
return <span className={timePassedClassName}></span>;
|
||||
};
|
||||
|
||||
function getClassName(value: string): string {
|
||||
|
|
|
|||
|
|
@ -3,17 +3,20 @@ import { Instance, types } from 'mobx-state-tree';
|
|||
import React from 'react';
|
||||
import Css from './top-right-menu.module.css';
|
||||
|
||||
export const Store = types.model({
|
||||
visible: types.boolean
|
||||
}).views((self) => {
|
||||
export const Store = types
|
||||
.model({
|
||||
visible: types.boolean,
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get style(): React.CSSProperties {
|
||||
return {
|
||||
display: self.visible ? 'block' : 'none'
|
||||
display: self.visible ? 'block' : 'none',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}).actions((self) => {
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
show: () => {
|
||||
self.visible = true;
|
||||
|
|
@ -23,7 +26,7 @@ export const Store = types.model({
|
|||
},
|
||||
toggle: () => {
|
||||
self.visible = !self.visible;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -40,16 +43,22 @@ export const TopRightMenu = observer((props: Props): JSX.Element => {
|
|||
menuItems.push(<li key={key}>{item}</li>);
|
||||
}
|
||||
} else if (props.children) {
|
||||
menuItems.push(<li key={0}>{props.children}</li>)
|
||||
menuItems.push(<li key={0}>{props.children}</li>);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button className={Css.menuButton} onClick={(e) => {e.stopPropagation(); props.store.toggle();}}>Menu</button>
|
||||
<button
|
||||
className={Css.menuButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.store.toggle();
|
||||
}}
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
<div className={Css.menu} style={props.store.style}>
|
||||
<ul>
|
||||
{menuItems}
|
||||
</ul>
|
||||
<ul>{menuItems}</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,18 +3,24 @@ import Css from './unreaded-flag.module.css';
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import { Instance, types } from 'mobx-state-tree';
|
||||
import { RedmineTypes } from '../redmine-types';
|
||||
import { GetIssueReadingTimestamp, SetIssueReadingTimestamp } from '../utils/unreaded-provider';
|
||||
import {
|
||||
GetIssueReadingTimestamp,
|
||||
SetIssueReadingTimestamp,
|
||||
} from '../utils/unreaded-provider';
|
||||
|
||||
export const Store = types.model({
|
||||
export const Store = types
|
||||
.model({
|
||||
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
|
||||
readingTimestamp: types.number
|
||||
}).actions((self) => {
|
||||
readingTimestamp: types.number,
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
read: () => {
|
||||
self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}).views((self) => {
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
getUpdatedTimestap(): number {
|
||||
if (self.issue.journals) {
|
||||
|
|
@ -27,7 +33,7 @@ export const Store = types.model({
|
|||
}
|
||||
}
|
||||
if (lastComment) {
|
||||
return (new Date(lastComment.created_on)).getTime();
|
||||
return new Date(lastComment.created_on).getTime();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
|
|
@ -38,9 +44,11 @@ export const Store = types.model({
|
|||
if (self.readingTimestamp < updatedTimestamp) {
|
||||
className += ` ${Css.unreaded}`;
|
||||
}
|
||||
console.debug(`Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`); // DEBUG
|
||||
console.debug(
|
||||
`Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`,
|
||||
); // DEBUG
|
||||
return className;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -48,17 +56,23 @@ export function CreateStoreFromLocalStorage(issue: RedmineTypes.ExtendedIssue) {
|
|||
const timestamp = GetIssueReadingTimestamp(issue.id);
|
||||
return Store.create({
|
||||
issue: issue,
|
||||
readingTimestamp: timestamp
|
||||
readingTimestamp: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof Store>
|
||||
}
|
||||
store: Instance<typeof Store>;
|
||||
};
|
||||
|
||||
export const UnreadedFlag = observer((props: Props): JSX.Element => {
|
||||
const className = props.store.getClassName();
|
||||
return (
|
||||
<span className={className} onClick={(e) => {e.stopPropagation(); props.store.read();}}></span>
|
||||
<span
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.store.read();
|
||||
}}
|
||||
></span>
|
||||
);
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,35 @@
|
|||
import React from "react";
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import StartPage from "./start-page/start-page";
|
||||
import UnknownPage from "./unknown-page";
|
||||
import { KanbanBoardsPage } from "./kanban-board/kanban-boards-page";
|
||||
import { IssuesListBoardPage } from "./issues-list-board/issues-list-boards-page";
|
||||
import React from 'react';
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import StartPage from './start-page/start-page';
|
||||
import UnknownPage from './unknown-page';
|
||||
import { KanbanBoardsPage } from './kanban-board/kanban-boards-page';
|
||||
import { IssuesListBoardPage } from './issues-list-board/issues-list-boards-page';
|
||||
import { DashboardsPage } from './dashboard/dashboards-page';
|
||||
import { DashboardPage } from './dashboard/dashboard-page';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: (<StartPage/>),
|
||||
path: '/',
|
||||
element: <StartPage />,
|
||||
},
|
||||
{
|
||||
path: "/kanban-board/:type/:name",
|
||||
element: (<KanbanBoardsPage/>)
|
||||
path: '/kanban-board/:type/:name',
|
||||
element: <KanbanBoardsPage />,
|
||||
},
|
||||
{
|
||||
path: "/issues-list-board/:type/:name",
|
||||
element: (<IssuesListBoardPage/>)
|
||||
path: '/issues-list-board/:type/:name',
|
||||
element: <IssuesListBoardPage />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: (<UnknownPage/>)
|
||||
}
|
||||
path: '/dashboards',
|
||||
element: <DashboardsPage />,
|
||||
},
|
||||
{
|
||||
path: '/dashboard/:id',
|
||||
element: <DashboardPage />,
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <UnknownPage />,
|
||||
},
|
||||
]);
|
||||
|
|
@ -16,12 +16,23 @@ export const Basement = (props: Props): JSX.Element => {
|
|||
<div className={BasementCss.basementGrid}>
|
||||
<div className={BasementCss.bottomContacts}>
|
||||
<a href="/">
|
||||
<img src={props.iconUrl} alt="event_emitter_eltex_loc" className={BasementCss.eventEmitterEltexLoc} />
|
||||
<img
|
||||
src={props.iconUrl}
|
||||
alt="event_emitter_eltex_loc"
|
||||
className={BasementCss.eventEmitterEltexLoc}
|
||||
/>
|
||||
<span>redmine-issue-event-emitter</span>
|
||||
</a>
|
||||
<p><a href={props.contactUrl}> Проект
|
||||
<span className={BasementCss.textBoxTextOrange}> Павел Гнедов</span>
|
||||
</a></p>
|
||||
<p>
|
||||
<a href={props.contactUrl}>
|
||||
{' '}
|
||||
Проект
|
||||
<span className={BasementCss.textBoxTextOrange}>
|
||||
{' '}
|
||||
Павел Гнедов
|
||||
</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={BasementCss.discuss}>
|
||||
|
|
@ -30,8 +41,12 @@ export const Basement = (props: Props): JSX.Element => {
|
|||
<p className={BasementCss.discussText}> ОБСУДИТЬ </p>
|
||||
</a>
|
||||
</div>
|
||||
<img src={props.characterUrl} width="100" alt="Сharacter" className={BasementCss.character02} />
|
||||
|
||||
<img
|
||||
src={props.characterUrl}
|
||||
width="100"
|
||||
alt="Сharacter"
|
||||
className={BasementCss.character02}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,7 @@ export type Props = {
|
|||
};
|
||||
|
||||
export const Content = (props: Props) => {
|
||||
return (
|
||||
<div className={ContentCss.content}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
return <div className={ContentCss.content}>{props.children}</div>;
|
||||
};
|
||||
|
||||
export default Content;
|
||||
|
|
@ -8,7 +8,11 @@ export type CoverProps = {
|
|||
export const Cover = (props: CoverProps) => {
|
||||
return (
|
||||
<div className={CoverCss.cover}>
|
||||
<img src="/images/Сharacter_01.png" alt="Сharacter" className={CoverCss.character} />
|
||||
<img
|
||||
src="/images/Сharacter_01.png"
|
||||
alt="Сharacter"
|
||||
className={CoverCss.character}
|
||||
/>
|
||||
<div className={CoverCss.info}>
|
||||
<h3>Redmine Issue Event Emitter</h3>
|
||||
<h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,20 @@ export type Props = {
|
|||
};
|
||||
|
||||
export const NotificationBlock = (props: Props) => {
|
||||
const taskTitle = props?.taskTitle
|
||||
? (<span className={NotificationBlockCss.text_box_text_blue}>{props.taskTitle} </span>)
|
||||
: (<></>);
|
||||
const taskTitle = props?.taskTitle ? (
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>
|
||||
{props.taskTitle}{' '}
|
||||
</span>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
return (
|
||||
<div className={NotificationBlockCss.message}>
|
||||
<img src={props.avatarUrl} alt="event_emitter_eltex_loc" className={NotificationBlockCss.event_emitter_eltex_loc_icon} />
|
||||
<img
|
||||
src={props.avatarUrl}
|
||||
alt="event_emitter_eltex_loc"
|
||||
className={NotificationBlockCss.event_emitter_eltex_loc_icon}
|
||||
/>
|
||||
<div className={NotificationBlockCss.text_box}>
|
||||
<p className={NotificationBlockCss.text_box_text}>
|
||||
{taskTitle}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import TopBar from './top-bar';
|
|||
|
||||
export const StartPageData = {
|
||||
contact: 'https://t.me/pavelgnedov',
|
||||
bot: 'https://t.me/eltex_event_emitter_bot'
|
||||
bot: 'https://t.me/eltex_event_emitter_bot',
|
||||
};
|
||||
|
||||
export const StartPage = () => {
|
||||
|
|
@ -19,83 +19,120 @@ export const StartPage = () => {
|
|||
<TopBar contact={StartPageData.contact} />
|
||||
<Cover telegramBotUrl={StartPageData.bot} />
|
||||
<Content>
|
||||
<ContentBlock title='Возможности'>
|
||||
<ContentBlock title="Возможности">
|
||||
<ul>
|
||||
<li>Уведомления в реальном времени о событиях из задач - изменения статусов, упоминания комментариев</li>
|
||||
<li>
|
||||
Уведомления в реальном времени о событиях из задач - изменения
|
||||
статусов, упоминания комментариев
|
||||
</li>
|
||||
<li>Генерация и управления отчётами о задачах</li>
|
||||
<li>Под капотом приложение фреймворк</li>
|
||||
</ul>
|
||||
</ContentBlock>
|
||||
<ContentBlock title='Функции telegram бота'>
|
||||
<ContentBlock title="Функции telegram бота">
|
||||
<ul>
|
||||
<li>Последний отчёт для дейли проект ECCM</li>
|
||||
<li>Дополнительные функции для разработчиков
|
||||
eccm:/current_issues_eccm - список текущих задач по статусам - выбираютсятолько задачи из актуальных версий в статусах, где нужна какая-то реакцияили возможна работа прямо сейчас</li>
|
||||
<li>Скриншоты уведомления от бота:
|
||||
Примеры уведомлений о новых задачах и об изменениях статусов:</li>
|
||||
<li>
|
||||
Дополнительные функции для разработчиков eccm:/current_issues_eccm
|
||||
- список текущих задач по статусам - выбираютсятолько задачи из
|
||||
актуальных версий в статусах, где нужна какая-то реакцияили
|
||||
возможна работа прямо сейчас
|
||||
</li>
|
||||
<li>
|
||||
Скриншоты уведомления от бота: Примеры уведомлений о новых задачах
|
||||
и об изменениях статусов:
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Feature #245005'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
taskTitle="Feature #245005"
|
||||
avatarUrl="/images/event_emitter_eltex_loc-49px.png"
|
||||
>
|
||||
Реализовать поддержку нового протокола: <br/><br/>
|
||||
Реализовать поддержку нового протокола: <br />
|
||||
<br />
|
||||
Стив Джобс изменил статус задачи с Feedback на Closed
|
||||
</NotificationBlock>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Feature #241201'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
taskTitle="Feature #241201"
|
||||
avatarUrl="/images/event_emitter_eltex_loc-49px.png"
|
||||
>
|
||||
Добавить поддержку новых моделей: <br/><br/>
|
||||
|
||||
Добавить поддержку новых моделей: <br />
|
||||
<br />
|
||||
Билл Гейтс создал новую задачу и назначил её на вас
|
||||
</NotificationBlock>
|
||||
|
||||
<p>Простые уведомления о движении задач - и больше ничего лишнего.
|
||||
<p>
|
||||
Простые уведомления о движении задач - и больше ничего лишнего.
|
||||
Пример уведомления по личному упоминанию в задаче:
|
||||
</p>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Question #230033'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
taskTitle="Question #230033"
|
||||
avatarUrl="/images/event_emitter_eltex_loc-49px.png"
|
||||
>
|
||||
Сергей Брин:<br/><br/>
|
||||
|
||||
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по описанию к этой задаче.
|
||||
Сергей Брин:
|
||||
<br />
|
||||
<br />
|
||||
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по
|
||||
описанию к этой задаче.
|
||||
</NotificationBlock>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Bug #191122'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
taskTitle="Bug #191122"
|
||||
avatarUrl="/images/event_emitter_eltex_loc-49px.png"
|
||||
>
|
||||
Исправление уязвимости<br/><br/>
|
||||
|
||||
Линус Торвальдс завершил разработку по задаче и передал вам на ревью<br/><br/>
|
||||
|
||||
Исправление уязвимости
|
||||
<br />
|
||||
<br />
|
||||
Линус Торвальдс завершил разработку по задаче и передал вам на ревью
|
||||
<br />
|
||||
<br />
|
||||
Кажется получилось поправить проблемку. Глянь мой MR.
|
||||
</NotificationBlock>
|
||||
|
||||
<p>Можно задавать коллегам вопросы прямо из комментария задачи, неотрываясь от её содержимого. Уведомление доставится в считанные минуты с ссылкой на задачу и информацией от кого это уведомление.</p>
|
||||
<p>Пример запроса моих текущих задач с помощью команды
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>/current_issues_eccm</span>
|
||||
<p>
|
||||
Можно задавать коллегам вопросы прямо из комментария задачи,
|
||||
неотрываясь от её содержимого. Уведомление доставится в считанные
|
||||
минуты с ссылкой на задачу и информацией от кого это уведомление.
|
||||
</p>
|
||||
<p>
|
||||
Пример запроса моих текущих задач с помощью команды
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>
|
||||
/current_issues_eccm
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<NotificationBlock
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Бьёрн Страуструп:<br/><br/>
|
||||
|
||||
Re-opened:<br/><br/>
|
||||
<span className={NotificationBlockCss.text_box_text_blue}> - Feature #223301: </span>
|
||||
Дополнить stdlib новыми функциями (прио - P4, версия - C++23)<br/><br/>
|
||||
In Progress:<br/><br/>
|
||||
<span className={NotificationBlockCss.text_box_text_blue}> - Question #223411:</span>
|
||||
<NotificationBlock avatarUrl="/images/event_emitter_eltex_loc-49px.png">
|
||||
Бьёрн Страуструп:
|
||||
<br />
|
||||
<br />
|
||||
Re-opened:
|
||||
<br />
|
||||
<br />
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>
|
||||
{' '}
|
||||
- Feature #223301:{' '}
|
||||
</span>
|
||||
Дополнить stdlib новыми функциями (прио - P4, версия - C++23)
|
||||
<br />
|
||||
<br />
|
||||
In Progress:
|
||||
<br />
|
||||
<br />
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>
|
||||
{' '}
|
||||
- Question #223411:
|
||||
</span>
|
||||
Выпуск релиза C++23 (прио - P4, версия - C++23)
|
||||
</NotificationBlock>
|
||||
</ContentBlock>
|
||||
</Content>
|
||||
<Basement contactUrl={StartPageData.contact} characterUrl='/images/Сharacter_02.png' iconUrl='/images/event_emitter_eltex_loc-32px.png'/>
|
||||
<Basement
|
||||
contactUrl={StartPageData.contact}
|
||||
characterUrl="/images/Сharacter_02.png"
|
||||
iconUrl="/images/event_emitter_eltex_loc-32px.png"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,16 +13,35 @@ const TopBar = (props: TopBarProps): ReactElement => {
|
|||
<div className={TopBarCss.containerTitle}>
|
||||
<div className={TopBarCss.logo}>
|
||||
<a href="/" className={TopBarCss.logo}>
|
||||
<img src={LogoImg} alt="event_emitter_eltex_loc" className={TopBarCss.eventEmitterEltexLoc} />
|
||||
<img
|
||||
src={LogoImg}
|
||||
alt="event_emitter_eltex_loc"
|
||||
className={TopBarCss.eventEmitterEltexLoc}
|
||||
/>
|
||||
<span>redmine-issue-event-emitter</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{props.children}
|
||||
|
||||
<p><a href="/" target="_blank"> #документация</a></p>
|
||||
<p><a href={props.contact} target="_blank" rel="noreferrer"> #контакты</a></p>
|
||||
<p><a href="https://gnedov.info/" target="_blank" rel="noreferrer"> #блог</a></p>
|
||||
<p>
|
||||
<a href="/" target="_blank">
|
||||
{' '}
|
||||
#документация
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href={props.contact} target="_blank" rel="noreferrer">
|
||||
{' '}
|
||||
#контакты
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://gnedov.info/" target="_blank" rel="noreferrer">
|
||||
{' '}
|
||||
#блог
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const UnknownPage = () => {
|
||||
return (
|
||||
<p>Unknown page</p>
|
||||
)
|
||||
return <p>Unknown page</p>;
|
||||
};
|
||||
|
||||
export default UnknownPage;
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import { onGetIssuesQueueSizeClick, onIssuesRefreshClick } from './service-actions';
|
||||
import {
|
||||
onGetIssuesQueueSizeClick,
|
||||
onIssuesRefreshClick,
|
||||
} from './service-actions';
|
||||
|
||||
export const IssuesForceRefreshButton = (): JSX.Element => {
|
||||
return (
|
||||
<button onClick={onIssuesRefreshClick}>Force issues refresh</button>
|
||||
);
|
||||
return <button onClick={onIssuesRefreshClick}>Force issues refresh</button>;
|
||||
};
|
||||
|
||||
export const GetIssuesQueueSizeButton = (): JSX.Element => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,30 @@
|
|||
import axios from "axios";
|
||||
import axios from 'axios';
|
||||
import React from 'react';
|
||||
|
||||
export const onIssuesRefreshClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
const rawInput = prompt("Force issues refresh (delimiters - space, comma, semicolon or tab)", "");
|
||||
const rawInput = prompt(
|
||||
'Force issues refresh (delimiters - space, comma, semicolon or tab)',
|
||||
'',
|
||||
);
|
||||
if (!rawInput) return;
|
||||
const list = rawInput.split(/[ ,;\t\n\r]/).map(item => Number(item)).filter(item => (Number.isFinite(item) && item > 0));
|
||||
const list = rawInput
|
||||
.split(/[ ,;\t\n\r]/)
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isFinite(item) && item > 0);
|
||||
if (!list) return;
|
||||
axios.post(`/redmine-event-emitter/append-issues`, list);
|
||||
};
|
||||
|
||||
export const onGetIssuesQueueSizeClick = async (e: React.MouseEvent): Promise<void> => {
|
||||
export const onGetIssuesQueueSizeClick = async (
|
||||
e: React.MouseEvent,
|
||||
): Promise<void> => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
const resp = await axios.get(`${process.env.REACT_APP_BACKEND}redmine-event-emitter/get-issues-queue-size`);
|
||||
const resp = await axios.get(
|
||||
`${process.env.REACT_APP_BACKEND}redmine-event-emitter/get-issues-queue-size`,
|
||||
);
|
||||
console.debug(`resp -`, resp); // DEBUG
|
||||
if (!resp || typeof resp.data !== 'number') return;
|
||||
alert(`Issues queue size - ${resp.data}`);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
* @param a
|
||||
* @returns
|
||||
*/
|
||||
export const SpentHoursToFixed = (a: number|string|null|undefined): string => {
|
||||
export const SpentHoursToFixed = (
|
||||
a: number | string | null | undefined,
|
||||
): string => {
|
||||
if (a === null || typeof a === 'undefined') return '-';
|
||||
const res = (typeof a === 'number') ? a : Number(a);
|
||||
const res = typeof a === 'number' ? a : Number(a);
|
||||
if (!Number.isFinite(res)) return '-';
|
||||
return `${parseFloat(res.toFixed(1))}`;
|
||||
};
|
||||
|
|
@ -1,19 +1,22 @@
|
|||
const formatStringToCamelCase = (str: string): string => {
|
||||
const splitted = str.split("-");
|
||||
const splitted = str.split('-');
|
||||
if (splitted.length === 1) return splitted[0];
|
||||
return (
|
||||
splitted[0] +
|
||||
splitted
|
||||
.slice(1)
|
||||
.map(word => word[0].toUpperCase() + word.slice(1))
|
||||
.join("")
|
||||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
export const getStyleObjectFromString = (str: string): Record<string, string> => {
|
||||
export const getStyleObjectFromString = (
|
||||
str: string,
|
||||
): Record<string, string> => {
|
||||
if (!str) return {};
|
||||
const style = {} as Record<string, string>;
|
||||
str.split(";").forEach(el => {
|
||||
const [property, value] = el.split(":");
|
||||
str.split(';').forEach((el) => {
|
||||
const [property, value] = el.split(':');
|
||||
if (!property) return;
|
||||
|
||||
const formattedProperty = formatStringToCamelCase(property.trim());
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ export function GetIssueReadingTimestamp(issueId: number): number {
|
|||
}
|
||||
|
||||
export function SetIssueReadingTimestamp(issueId: number): number {
|
||||
const now = (new Date()).getTime();
|
||||
const now = new Date().getTime();
|
||||
window.localStorage.setItem(getKey(issueId), String(now));
|
||||
return now;
|
||||
}
|
||||
|
||||
export function SetIssuesReadingTimestamp(issueIds: number[]): number {
|
||||
const now = (new Date()).getTime();
|
||||
const now = new Date().getTime();
|
||||
for (let i = 0; i < issueIds.length; i++) {
|
||||
const issueId = issueIds[i];
|
||||
window.localStorage.setItem(getKey(issueId), String(now));
|
||||
|
|
|
|||
36
libs/event-emitter/src/couchdb-datasources/dashboards.ts
Normal file
36
libs/event-emitter/src/couchdb-datasources/dashboards.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import nano from 'nano';
|
||||
import * as DashboardModel from '../models/dashboard';
|
||||
import { CouchDb } from './couchdb';
|
||||
import configuration from '../configs/main-config';
|
||||
|
||||
const config = configuration();
|
||||
|
||||
@Injectable()
|
||||
export class Dashboards {
|
||||
private static logger = new Logger(Dashboards.name);
|
||||
private static dashboardsDb = null;
|
||||
private static initialized = false;
|
||||
|
||||
static async getDatasource(): Promise<
|
||||
nano.DocumentScope<DashboardModel.Dashboard>
|
||||
> {
|
||||
if (Dashboards.initialized) {
|
||||
return Dashboards.dashboardsDb;
|
||||
}
|
||||
Dashboards.initialized = true;
|
||||
const n = CouchDb.getCouchDb();
|
||||
const dashboardsDbName = config.couchDb?.dbs?.dashboards;
|
||||
const dbs = await n.db.list();
|
||||
if (!dbs.includes(dashboardsDbName)) {
|
||||
await n.db.create(dashboardsDbName);
|
||||
}
|
||||
Dashboards.dashboardsDb = await n.db.use(dashboardsDbName);
|
||||
Dashboards.logger.log(`Connected to dashboards db - ${dashboardsDbName}`);
|
||||
return Dashboards.dashboardsDb;
|
||||
}
|
||||
|
||||
async getDatasource(): Promise<nano.DocumentScope<DashboardModel.Dashboard>> {
|
||||
return await Dashboards.getDatasource();
|
||||
}
|
||||
}
|
||||
58
libs/event-emitter/src/dashboards/dashboard.controller.ts
Normal file
58
libs/event-emitter/src/dashboards/dashboard.controller.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { DashboardsService } from './dashboards.service';
|
||||
import { BadRequestErrorHandler, getOrAppErrorOrThrow } from '../utils/result';
|
||||
import { DashboardsDataService } from './dashboards-data.service';
|
||||
|
||||
@Controller('api/dashboard')
|
||||
export class DashboardController {
|
||||
constructor(
|
||||
private dashboardsService: DashboardsService,
|
||||
private dashboardsDataService: DashboardsDataService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async create(): Promise<string> {
|
||||
const res = await getOrAppErrorOrThrow(
|
||||
() => this.dashboardsService.create(),
|
||||
BadRequestErrorHandler,
|
||||
);
|
||||
return res.id;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async load(@Param('id') id: string): Promise<any> {
|
||||
const res = await getOrAppErrorOrThrow(
|
||||
() => this.dashboardsService.load(id),
|
||||
BadRequestErrorHandler,
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
@Get(':id/load-data')
|
||||
async loadData(@Param('id') id: string): Promise<any> {
|
||||
return await getOrAppErrorOrThrow(
|
||||
() => this.dashboardsDataService.loadData(id),
|
||||
BadRequestErrorHandler,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/load-data/:widgetId')
|
||||
async loadDataForWidget(
|
||||
@Param('id') id: string,
|
||||
@Param('widgetId') widgetId: string,
|
||||
): Promise<any> {
|
||||
return await getOrAppErrorOrThrow(
|
||||
() => this.dashboardsDataService.loadDataForWidget(id, widgetId),
|
||||
BadRequestErrorHandler,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async save(@Param('id') id: string, @Body() data: any): Promise<void> {
|
||||
const res = await getOrAppErrorOrThrow(
|
||||
() => this.dashboardsService.save(id, data),
|
||||
BadRequestErrorHandler,
|
||||
);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
79
libs/event-emitter/src/dashboards/dashboards-data.service.ts
Normal file
79
libs/event-emitter/src/dashboards/dashboards-data.service.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DashboardsService } from './dashboards.service';
|
||||
import * as DashboardModel from '../models/dashboard';
|
||||
import { AppError, Result, createAppError, fail } from '../utils/result';
|
||||
import { WidgetsCollectionService } from './widgets-collection.service';
|
||||
|
||||
export type WidgetWithData = {
|
||||
widgetId: string;
|
||||
data: any;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DashboardsDataService {
|
||||
private logger = new Logger(DashboardsDataService.name);
|
||||
|
||||
constructor(
|
||||
private dashboardsService: DashboardsService,
|
||||
private widgetsCollectionService: WidgetsCollectionService,
|
||||
) {}
|
||||
|
||||
async loadData(id: string): Promise<WidgetWithData[]> {
|
||||
const cfg = await this.dashboardsService.load(id);
|
||||
const results: WidgetWithData[] = [];
|
||||
let isSuccess = false;
|
||||
if (!cfg?.widgets || cfg?.widgets?.length <= 0) {
|
||||
return results;
|
||||
}
|
||||
for (let i = 0; i < cfg.widgets.length; i++) {
|
||||
const widget = cfg.widgets[i];
|
||||
if (widget.collapsed) continue;
|
||||
const loadRes = await this.loadWidgetData(
|
||||
widget.type,
|
||||
widget.widgetParams,
|
||||
widget.dataLoaderParams,
|
||||
cfg,
|
||||
);
|
||||
if (loadRes.result) {
|
||||
isSuccess = true;
|
||||
loadRes.result.widgetId = widget.id;
|
||||
results.push({ data: loadRes.result, widgetId: widget.id });
|
||||
}
|
||||
}
|
||||
if (!isSuccess) throw createAppError('CANNOT_LOAD_DATA');
|
||||
return results;
|
||||
}
|
||||
|
||||
async loadDataForWidget(id: string, widgetId: string): Promise<any> {
|
||||
const cfg = await this.dashboardsService.load(id);
|
||||
const widget = cfg.widgets.find((widget) => {
|
||||
return widget.id == widgetId;
|
||||
});
|
||||
if (!widget) throw createAppError('WIDGET_NOT_FOUND');
|
||||
const loadRes = await this.loadWidgetData(
|
||||
widget.type,
|
||||
widget.widgetParams,
|
||||
widget.dataLoaderParams,
|
||||
cfg,
|
||||
);
|
||||
if (loadRes.result) return loadRes.result;
|
||||
throw createAppError('CANNOT_LOAD_DATA');
|
||||
}
|
||||
|
||||
async loadWidgetData(
|
||||
type: string,
|
||||
widgetParams: DashboardModel.WidgetParams,
|
||||
dataLoaderParams: DashboardModel.DataLoaderParams,
|
||||
dashboardParams: DashboardModel.Data,
|
||||
): Promise<Result<any, AppError>> {
|
||||
const widgetResult = this.widgetsCollectionService.getWidgetByType(type);
|
||||
if (widgetResult.error) return fail(createAppError(widgetResult.error));
|
||||
const widget = widgetResult.result;
|
||||
const renderResult = await widget.render(
|
||||
widgetParams,
|
||||
dataLoaderParams,
|
||||
dashboardParams,
|
||||
);
|
||||
return renderResult;
|
||||
}
|
||||
}
|
||||
18
libs/event-emitter/src/dashboards/dashboards.controller.ts
Normal file
18
libs/event-emitter/src/dashboards/dashboards.controller.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DashboardsService } from './dashboards.service';
|
||||
import { BadRequestErrorHandler, getOrAppErrorOrThrow } from '../utils/result';
|
||||
import { UNLIMITED } from '../consts/consts';
|
||||
|
||||
@Controller('api/dashboards')
|
||||
export class DashboardsController {
|
||||
constructor(private dashboardsService: DashboardsService) {}
|
||||
|
||||
@Get()
|
||||
async list(): Promise<any> {
|
||||
const res = await getOrAppErrorOrThrow(
|
||||
() => this.dashboardsService.publicList(UNLIMITED),
|
||||
BadRequestErrorHandler,
|
||||
);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
89
libs/event-emitter/src/dashboards/dashboards.service.ts
Normal file
89
libs/event-emitter/src/dashboards/dashboards.service.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Dashboards as DashboardsDb } from '../couchdb-datasources/dashboards';
|
||||
import * as DashboardModel from '../models/dashboard';
|
||||
import nano from 'nano';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createAppError } from '../utils/result';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardsService {
|
||||
private logger = new Logger(DashboardsService.name);
|
||||
|
||||
constructor(private db: DashboardsDb) {}
|
||||
|
||||
async create(): Promise<DashboardModel.Dashboard> {
|
||||
const id = randomUUID();
|
||||
this.logger.debug(`Create new dashboard with id - ${id}`);
|
||||
if (await this.isExists(id)) {
|
||||
const err = createAppError('ALREADY_EXISTS');
|
||||
this.logger.error(`Error - ${JSON.stringify(err)}`);
|
||||
throw err;
|
||||
}
|
||||
const ds = await this.db.getDatasource();
|
||||
const doc: nano.MaybeDocument & DashboardModel.Dashboard = {
|
||||
_id: id,
|
||||
id: id,
|
||||
data: null,
|
||||
};
|
||||
await ds.insert(doc);
|
||||
return await ds.get(id);
|
||||
}
|
||||
|
||||
async loadRawData(
|
||||
id: string,
|
||||
): Promise<DashboardModel.Dashboard & nano.MaybeDocument> {
|
||||
this.logger.debug(`Load raw data, dashboard id - ${id}`);
|
||||
const ds = await this.db.getDatasource();
|
||||
if (!(await this.isExists(id))) throw createAppError('NOT_EXISTS');
|
||||
const res = await ds.get(id);
|
||||
return res;
|
||||
}
|
||||
|
||||
async load(id: string): Promise<DashboardModel.Data> {
|
||||
this.logger.debug(`Load dashboard id - ${id}`);
|
||||
const rawData = await this.loadRawData(id);
|
||||
return rawData?.data || { widgets: [] };
|
||||
}
|
||||
|
||||
async isExists(id: string): Promise<boolean> {
|
||||
const ds = await this.db.getDatasource();
|
||||
try {
|
||||
await ds.get(id);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async save(id: string, data: DashboardModel.Data): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Save dashboard id - ${id}, data - ${JSON.stringify(data)}`,
|
||||
);
|
||||
const ds = await this.db.getDatasource();
|
||||
const prevValue = await this.loadRawData(id);
|
||||
|
||||
const newValue = {
|
||||
_id: prevValue._id,
|
||||
_rev: prevValue._rev,
|
||||
id: prevValue.id,
|
||||
data: data,
|
||||
};
|
||||
await ds.insert(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
async publicList(limit: number): Promise<{ id: string; title: string }[]> {
|
||||
const ds = await this.db.getDatasource();
|
||||
const data = await ds.find({
|
||||
selector: {
|
||||
'data.title': {
|
||||
$exists: true,
|
||||
},
|
||||
},
|
||||
fields: ['id', 'data.title'],
|
||||
limit: limit,
|
||||
});
|
||||
if (!data.docs) throw createAppError('DASHBOARDS_NOT_FOUND');
|
||||
return data.docs.map((d) => ({ id: d.id, title: d.data.title }));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Result, AppError, fail, success } from '../utils/result';
|
||||
import { WidgetDataLoaderInterface } from './widget-data-loader-interface';
|
||||
import { WidgetInterface } from './widget-interface';
|
||||
|
||||
export class InteractiveWidget
|
||||
implements WidgetInterface<any, any, any, any, any>
|
||||
{
|
||||
constructor(
|
||||
public dataLoader: WidgetDataLoaderInterface<any, any, any>,
|
||||
public type: string,
|
||||
) {}
|
||||
|
||||
async render(
|
||||
widgetParams: any,
|
||||
dataLoaderParams: any,
|
||||
dashboardParams: any,
|
||||
): Promise<Result<any, AppError>> {
|
||||
const data = await this.dataLoader.load(dataLoaderParams, dashboardParams);
|
||||
return data.error ? fail(data.error) : success(data.result);
|
||||
}
|
||||
}
|
||||
|
||||
export function createInteractiveWidget(
|
||||
dataLoader: WidgetDataLoaderInterface<any, any, any>,
|
||||
type: string,
|
||||
): WidgetInterface<any, any, any, any, any> {
|
||||
return new InteractiveWidget(dataLoader, type);
|
||||
}
|
||||
35
libs/event-emitter/src/dashboards/text-widget-factory.ts
Normal file
35
libs/event-emitter/src/dashboards/text-widget-factory.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Result, AppError, success } from '../utils/result';
|
||||
import { WidgetDataLoaderInterface } from './widget-data-loader-interface';
|
||||
import { WidgetInterface } from './widget-interface';
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
export class TextWidget implements WidgetInterface<any, any, any, any, any> {
|
||||
constructor(
|
||||
public dataLoader: WidgetDataLoaderInterface<any, any, any>,
|
||||
public type: string,
|
||||
public template: string,
|
||||
) {}
|
||||
|
||||
async render(
|
||||
widgetParams: any,
|
||||
dataLoaderParams: any,
|
||||
dashboardParams: any,
|
||||
): Promise<Result<any, AppError>> {
|
||||
const params = {
|
||||
widgetParams,
|
||||
dataLoaderParams,
|
||||
dashboardParams,
|
||||
};
|
||||
const template = Handlebars.compile(this.template);
|
||||
const res = template(params);
|
||||
return success(res);
|
||||
}
|
||||
}
|
||||
|
||||
export function createTextWidget(
|
||||
dataLoader: WidgetDataLoaderInterface<any, any, any>,
|
||||
type: string,
|
||||
template: string,
|
||||
): WidgetInterface<any, any, any, any, any> {
|
||||
return new TextWidget(dataLoader, type, template);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { AppError, Result } from '../utils/result';
|
||||
|
||||
export interface WidgetDataLoaderInterface<DLP, DBP, R> {
|
||||
isMyConfig(dataLoaderParams: DLP): boolean;
|
||||
load(
|
||||
dataLoaderParams: DLP,
|
||||
dashboardParams: DBP,
|
||||
): Promise<Result<R, AppError>>;
|
||||
}
|
||||
|
|
@ -12,8 +12,14 @@ import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from
|
|||
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import nano from 'nano';
|
||||
import { WidgetInterface } from '../widget-interface';
|
||||
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer';
|
||||
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
|
||||
import {
|
||||
AppError,
|
||||
Result,
|
||||
createAppError,
|
||||
success,
|
||||
} from '@app/event-emitter/utils/result';
|
||||
|
||||
export namespace ListIssuesByFieldsWidgetNs {
|
||||
export type Params = {
|
||||
|
|
@ -34,10 +40,10 @@ export namespace ListIssuesByFieldsWidgetNs {
|
|||
type Params = ListIssuesByFieldsWidgetNs.Params;
|
||||
|
||||
@Injectable()
|
||||
export class ListIssuesByFieldsWidgetService
|
||||
implements WidgetInterface<Params, any, any>
|
||||
export class ListIssuesByFieldsWidgetDataLoaderService
|
||||
implements WidgetDataLoaderInterface<Params, any, any>
|
||||
{
|
||||
private logger = new Logger(ListIssuesByFieldsWidgetService.name);
|
||||
private logger = new Logger(ListIssuesByFieldsWidgetDataLoaderService.name);
|
||||
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||
|
||||
constructor(
|
||||
|
|
@ -52,7 +58,7 @@ export class ListIssuesByFieldsWidgetService
|
|||
return true;
|
||||
}
|
||||
|
||||
async render(widgetParams: Params): Promise<any> {
|
||||
async load(widgetParams: Params): Promise<Result<any, AppError>> {
|
||||
let store: FlatIssuesStore;
|
||||
if (widgetParams.fromRootIssueId) {
|
||||
store = await this.getListFromRoot(widgetParams.fromRootIssueId);
|
||||
|
|
@ -61,7 +67,7 @@ export class ListIssuesByFieldsWidgetService
|
|||
} else {
|
||||
const errMsg = `Wrong widgetParams value`;
|
||||
this.logger.error(errMsg);
|
||||
throw new Error(errMsg);
|
||||
return fail(createAppError(errMsg));
|
||||
}
|
||||
await store.enhanceIssues([
|
||||
this.timePassedHighlightEnhancer,
|
||||
|
|
@ -87,7 +93,7 @@ export class ListIssuesByFieldsWidgetService
|
|||
return a.metainfo.title.localeCompare(b.metainfo.title);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
return success(res);
|
||||
}
|
||||
|
||||
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {
|
||||
|
|
@ -11,8 +11,14 @@ import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from
|
|||
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import nano from 'nano';
|
||||
import { WidgetInterface } from '../widget-interface';
|
||||
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer';
|
||||
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
|
||||
import {
|
||||
AppError,
|
||||
Result,
|
||||
createAppError,
|
||||
success,
|
||||
} from '@app/event-emitter/utils/result';
|
||||
|
||||
export namespace ListIssuesByUsersLikeJiraWidgetNs {
|
||||
export namespace Models {
|
||||
|
|
@ -29,15 +35,17 @@ export namespace ListIssuesByUsersLikeJiraWidgetNs {
|
|||
type Params = ListIssuesByUsersLikeJiraWidgetNs.Models.Params;
|
||||
|
||||
@Injectable()
|
||||
export class ListIssuesByUsersLikeJiraWidgetService
|
||||
implements WidgetInterface<Params, any, any>
|
||||
export class ListIssuesByUsersLikeJiraWidgetDataLoaderService
|
||||
implements WidgetDataLoaderInterface<Params, any, any>
|
||||
{
|
||||
private logger = new Logger(ListIssuesByUsersLikeJiraWidgetService.name);
|
||||
private logger = new Logger(
|
||||
ListIssuesByUsersLikeJiraWidgetDataLoaderService.name,
|
||||
);
|
||||
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||
|
||||
constructor(
|
||||
private issuesService: IssuesService,
|
||||
private timePassedHighlightEnhancer: TimePassedHighlightEnhancer,
|
||||
private issuesService: IssuesService,
|
||||
private issueUrlEnhancer: IssueUrlEnhancer,
|
||||
) {
|
||||
this.issuesLoader = this.issuesService.createDynamicIssuesLoader();
|
||||
|
|
@ -47,7 +55,7 @@ export class ListIssuesByUsersLikeJiraWidgetService
|
|||
return true;
|
||||
}
|
||||
|
||||
async render(widgetParams: Params): Promise<any> {
|
||||
async load(widgetParams: Params): Promise<Result<any, AppError>> {
|
||||
let store: FlatIssuesStore;
|
||||
if (widgetParams.fromRootIssueId) {
|
||||
store = await this.getListFromRoot(widgetParams.fromRootIssueId);
|
||||
|
|
@ -56,7 +64,7 @@ export class ListIssuesByUsersLikeJiraWidgetService
|
|||
} else {
|
||||
const errMsg = `Wrong widgetParams value`;
|
||||
this.logger.error(errMsg);
|
||||
throw new Error(errMsg);
|
||||
return fail(createAppError(errMsg));
|
||||
}
|
||||
await store.enhanceIssues([
|
||||
this.timePassedHighlightEnhancer,
|
||||
|
|
@ -92,7 +100,7 @@ export class ListIssuesByUsersLikeJiraWidgetService
|
|||
return a.metainfo.title.localeCompare(b.metainfo.title);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
return success(res);
|
||||
}
|
||||
|
||||
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {
|
||||
|
|
@ -12,8 +12,14 @@ import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from
|
|||
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import nano from 'nano';
|
||||
import { WidgetInterface } from '../widget-interface';
|
||||
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer';
|
||||
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
|
||||
import {
|
||||
AppError,
|
||||
Result,
|
||||
createAppError,
|
||||
success,
|
||||
} from '@app/event-emitter/utils/result';
|
||||
|
||||
export namespace ListIssuesByUsersWidgetNs {
|
||||
export namespace Models {
|
||||
|
|
@ -41,10 +47,10 @@ type ExtendedIssue = RedmineTypes.Issue & Record<string, any>;
|
|||
type FindResult = ListIssuesByUsersWidgetNs.Models.FindResult;
|
||||
|
||||
@Injectable()
|
||||
export class ListIssuesByUsersWidgetService
|
||||
implements WidgetInterface<Params, any, any>
|
||||
export class ListIssuesByUsersWidgetDataLoaderService
|
||||
implements WidgetDataLoaderInterface<Params, any, any>
|
||||
{
|
||||
private logger = new Logger(ListIssuesByUsersWidgetService.name);
|
||||
private logger = new Logger(ListIssuesByUsersWidgetDataLoaderService.name);
|
||||
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||
|
||||
constructor(
|
||||
|
|
@ -59,7 +65,7 @@ export class ListIssuesByUsersWidgetService
|
|||
return true;
|
||||
}
|
||||
|
||||
async render(widgetParams: Params): Promise<any> {
|
||||
async load(widgetParams: Params): Promise<Result<any, AppError>> {
|
||||
let store: FlatIssuesStore;
|
||||
if (widgetParams.fromRootIssueId) {
|
||||
store = await this.getListFromRoot(widgetParams.fromRootIssueId);
|
||||
|
|
@ -68,7 +74,7 @@ export class ListIssuesByUsersWidgetService
|
|||
} else {
|
||||
const errMsg = `Wrong widgetParams value`;
|
||||
this.logger.error(errMsg);
|
||||
throw new Error(errMsg);
|
||||
return fail(createAppError(errMsg));
|
||||
}
|
||||
await store.enhanceIssues([
|
||||
this.timePassedHighlightEnhancer,
|
||||
|
|
@ -95,7 +101,7 @@ export class ListIssuesByUsersWidgetService
|
|||
return a.metainfo.title.localeCompare(b.metainfo.title);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
return success(res);
|
||||
}
|
||||
|
||||
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {
|
||||
|
|
@ -11,8 +11,9 @@ import {
|
|||
TreeIssuesStoreNs,
|
||||
} from '@app/event-emitter/utils/tree-issues-store';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { WidgetInterface } from '../widget-interface';
|
||||
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer';
|
||||
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
|
||||
import { AppError, Result, success } from '@app/event-emitter/utils/result';
|
||||
|
||||
export namespace RootIssueSubTreesWidgetNs {
|
||||
export namespace Models {
|
||||
|
|
@ -39,8 +40,8 @@ export namespace RootIssueSubTreesWidgetNs {
|
|||
type Params = RootIssueSubTreesWidgetNs.Models.Params;
|
||||
|
||||
@Injectable()
|
||||
export class RootIssueSubTreesWidgetService
|
||||
implements WidgetInterface<Params, any, any>
|
||||
export class RootIssueSubTreesWidgetDataLoaderService
|
||||
implements WidgetDataLoaderInterface<Params, any, any>
|
||||
{
|
||||
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ export class RootIssueSubTreesWidgetService
|
|||
return true;
|
||||
}
|
||||
|
||||
async render(widgetParams: Params): Promise<any> {
|
||||
async load(widgetParams: Params): Promise<Result<any, AppError>> {
|
||||
const treeStore = new TreeIssuesStore();
|
||||
const rootIssue = await this.issuesService.getIssue(
|
||||
widgetParams.rootIssueId,
|
||||
|
|
@ -89,11 +90,12 @@ export class RootIssueSubTreesWidgetService
|
|||
}
|
||||
}
|
||||
}
|
||||
return stories.map((s) => {
|
||||
const res = stories.map((s) => {
|
||||
return {
|
||||
data: s.store.groupByStatus(widgetParams.statuses),
|
||||
metainfo: s.metainfo,
|
||||
};
|
||||
});
|
||||
return success(res);
|
||||
}
|
||||
}
|
||||
19
libs/event-emitter/src/dashboards/widget-interface.ts
Normal file
19
libs/event-emitter/src/dashboards/widget-interface.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { AppError, Result } from '../utils/result';
|
||||
import { WidgetDataLoaderInterface } from './widget-data-loader-interface';
|
||||
|
||||
/**
|
||||
* - WP - widget params
|
||||
* - DLP - dataloader params
|
||||
* - DBP - dashboard params
|
||||
* - DLR - dataloader result
|
||||
* - R - result
|
||||
*/
|
||||
export interface WidgetInterface<WP, DLP, DBP, DLR, R> {
|
||||
dataLoader: WidgetDataLoaderInterface<DLP, DBP, DLR>;
|
||||
type: string;
|
||||
render(
|
||||
widgetParams: WP,
|
||||
dataLoaderParams: DLP,
|
||||
dashboardParams: DBP,
|
||||
): Promise<Result<R, AppError>>;
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { WidgetInterface } from './widget-interface';
|
||||
import { ListIssuesByFieldsWidgetDataLoaderService } from './widget-data-loader/list-issues-by-fields.widget-data-loader.service';
|
||||
import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from './widget-data-loader/list-issues-by-users-like-jira.widget-data-loader.service';
|
||||
import { RootIssueSubTreesWidgetDataLoaderService } from './widget-data-loader/root-issue-subtrees.widget-data-loader.service';
|
||||
import { createInteractiveWidget } from './interactive-widget-factory';
|
||||
import { Result, success } from '@app/event-emitter/utils/result';
|
||||
|
||||
@Injectable()
|
||||
export class WidgetsCollectionService {
|
||||
collection: WidgetInterface<any, any, any, any, any>[] = [];
|
||||
|
||||
constructor(
|
||||
private listIssuesByFieldsWidgetDataLoaderService: ListIssuesByFieldsWidgetDataLoaderService,
|
||||
private listIssuesByUsersLikeJiraWidgetDataLoaderService: ListIssuesByUsersLikeJiraWidgetDataLoaderService,
|
||||
private rootIssueSubTreesWidgetDataLoaderService: RootIssueSubTreesWidgetDataLoaderService,
|
||||
) {
|
||||
const collection = [
|
||||
createInteractiveWidget(
|
||||
this.listIssuesByFieldsWidgetDataLoaderService,
|
||||
'kanban_by_fields',
|
||||
),
|
||||
createInteractiveWidget(
|
||||
this.listIssuesByUsersLikeJiraWidgetDataLoaderService,
|
||||
'kanban_by_users',
|
||||
),
|
||||
createInteractiveWidget(
|
||||
this.rootIssueSubTreesWidgetDataLoaderService,
|
||||
'kanban_by_tree',
|
||||
),
|
||||
createInteractiveWidget(
|
||||
this.listIssuesByFieldsWidgetDataLoaderService,
|
||||
'issues_list_by_fields',
|
||||
),
|
||||
createInteractiveWidget(
|
||||
this.listIssuesByUsersLikeJiraWidgetDataLoaderService,
|
||||
'issues_list_by_users',
|
||||
),
|
||||
createInteractiveWidget(
|
||||
this.rootIssueSubTreesWidgetDataLoaderService,
|
||||
'issues_list_by_tree',
|
||||
),
|
||||
];
|
||||
|
||||
collection.forEach((w) => this.appendWidget(w));
|
||||
}
|
||||
|
||||
appendWidget(
|
||||
widget: WidgetInterface<any, any, any, any, any>,
|
||||
): Result<true, string> {
|
||||
const type = widget.type;
|
||||
const isExists = this.collection.find((w) => w.type === type);
|
||||
if (isExists) return fail('WIDGET_WITH_SAME_TYPE_ALREADY_EXISTS');
|
||||
this.collection.push(widget);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
getWidgetTypes(): string[] {
|
||||
return this.collection.map((w) => w.type);
|
||||
}
|
||||
|
||||
getWidgetByType(
|
||||
type: string,
|
||||
): Result<WidgetInterface<any, any, any, any, any>, string> {
|
||||
const widget = this.collection.find((w) => w.type === type);
|
||||
return widget ? success(widget) : fail('WIDGET_WITH_SAME_TYPE_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
|
@ -18,19 +18,24 @@ import { IssuesService } from './issues/issues.service';
|
|||
import { IssuesController } from './issues/issues.controller';
|
||||
import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer';
|
||||
import { EnhancerService } from './issue-enhancers/enhancer.service';
|
||||
import { ProjectDashboardService } from './project-dashboard/project-dashboard.service';
|
||||
import { RootIssueSubTreesWidgetService } from './project-dashboard/widgets/root-issue-subtrees.widget.service';
|
||||
import { DynamicLoader } from './configs/dynamic-loader';
|
||||
import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter';
|
||||
import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer';
|
||||
import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list-issues-by-users.widget.service';
|
||||
import { ListIssuesByUsersLikeJiraWidgetService } from './project-dashboard/widgets/list-issues-by-users-like-jira.widget.service';
|
||||
import { TimePassedHighlightEnhancer } from './issue-enhancers/time-passed-highlight-enhancer';
|
||||
import { ListIssuesByFieldsWidgetService } from './project-dashboard/widgets/list-issues-by-fields.widget.service';
|
||||
import { IssuesUpdaterService } from './issues-updater/issues-updater.service';
|
||||
import { CalendarEnhancer } from './issue-enhancers/calendar-enhancer';
|
||||
import { CalendarService } from './calendar/calendar.service';
|
||||
import { CalendarController } from './calendar/calendar.controller';
|
||||
import { Dashboards as DashboardsDs } from './couchdb-datasources/dashboards';
|
||||
import { DashboardController } from './dashboards/dashboard.controller';
|
||||
import { DashboardsService } from './dashboards/dashboards.service';
|
||||
import { DashboardsDataService } from './dashboards/dashboards-data.service';
|
||||
import { RootIssueSubTreesWidgetDataLoaderService } from './dashboards/widget-data-loader/root-issue-subtrees.widget-data-loader.service';
|
||||
import { ListIssuesByUsersWidgetDataLoaderService } from './dashboards/widget-data-loader/list-issues-by-users.widget-data-loader.service';
|
||||
import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from './dashboards/widget-data-loader/list-issues-by-users-like-jira.widget-data-loader.service';
|
||||
import { ListIssuesByFieldsWidgetDataLoaderService } from './dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service';
|
||||
import { WidgetsCollectionService } from './dashboards/widgets-collection.service';
|
||||
import { DashboardsController } from './dashboards/dashboards.controller';
|
||||
|
||||
@Module({})
|
||||
export class EventEmitterModule implements OnModuleInit {
|
||||
|
|
@ -48,20 +53,20 @@ export class EventEmitterModule implements OnModuleInit {
|
|||
CouchDb,
|
||||
Users,
|
||||
Issues,
|
||||
DashboardsDs,
|
||||
RedmineUserCacheWriterService,
|
||||
UsersService,
|
||||
IssuesService,
|
||||
TimestampEnhancer,
|
||||
EnhancerService,
|
||||
ProjectDashboardService,
|
||||
RootIssueSubTreesWidgetService,
|
||||
RootIssueSubTreesWidgetDataLoaderService,
|
||||
DynamicLoader,
|
||||
RedminePublicUrlConverter,
|
||||
IssueUrlEnhancer,
|
||||
ListIssuesByUsersWidgetService,
|
||||
ListIssuesByUsersLikeJiraWidgetService,
|
||||
ListIssuesByUsersWidgetDataLoaderService,
|
||||
ListIssuesByUsersLikeJiraWidgetDataLoaderService,
|
||||
TimePassedHighlightEnhancer,
|
||||
ListIssuesByFieldsWidgetService,
|
||||
ListIssuesByFieldsWidgetDataLoaderService,
|
||||
{
|
||||
provide: 'ISSUES_UPDATER_SERVICE',
|
||||
useFactory: (configService: ConfigService) => {
|
||||
|
|
@ -87,12 +92,18 @@ export class EventEmitterModule implements OnModuleInit {
|
|||
},
|
||||
{
|
||||
provide: 'CALENDAR_SERVICE',
|
||||
useFactory: (calendarEnhancer: CalendarEnhancer, issuesService: IssuesService): CalendarService => {
|
||||
useFactory: (
|
||||
calendarEnhancer: CalendarEnhancer,
|
||||
issuesService: IssuesService,
|
||||
): CalendarService => {
|
||||
const calendarEventsKey = calendarEnhancer.calendarEventsKey;
|
||||
return new CalendarService(calendarEventsKey, issuesService);
|
||||
},
|
||||
inject: ['CALENDAR_ENHANCER', IssuesService]
|
||||
inject: ['CALENDAR_ENHANCER', IssuesService],
|
||||
},
|
||||
DashboardsService,
|
||||
DashboardsDataService,
|
||||
WidgetsCollectionService,
|
||||
],
|
||||
exports: [
|
||||
EventEmitterService,
|
||||
|
|
@ -102,20 +113,20 @@ export class EventEmitterModule implements OnModuleInit {
|
|||
CouchDb,
|
||||
Users,
|
||||
Issues,
|
||||
DashboardsDs,
|
||||
RedmineUserCacheWriterService,
|
||||
UsersService,
|
||||
IssuesService,
|
||||
TimestampEnhancer,
|
||||
EnhancerService,
|
||||
ProjectDashboardService,
|
||||
RootIssueSubTreesWidgetService,
|
||||
RootIssueSubTreesWidgetDataLoaderService,
|
||||
DynamicLoader,
|
||||
RedminePublicUrlConverter,
|
||||
IssueUrlEnhancer,
|
||||
ListIssuesByUsersWidgetService,
|
||||
ListIssuesByUsersLikeJiraWidgetService,
|
||||
ListIssuesByUsersWidgetDataLoaderService,
|
||||
ListIssuesByUsersLikeJiraWidgetDataLoaderService,
|
||||
TimePassedHighlightEnhancer,
|
||||
ListIssuesByFieldsWidgetService,
|
||||
ListIssuesByFieldsWidgetDataLoaderService,
|
||||
{
|
||||
provide: 'ISSUES_UPDATER_SERVICE',
|
||||
useExisting: 'ISSUES_UPDATER_SERVICE',
|
||||
|
|
@ -128,8 +139,18 @@ export class EventEmitterModule implements OnModuleInit {
|
|||
provide: 'CALENDAR_SERVICE',
|
||||
useExisting: 'CALENDAR_SERVICE',
|
||||
},
|
||||
DashboardsService,
|
||||
DashboardsDataService,
|
||||
WidgetsCollectionService,
|
||||
],
|
||||
controllers: [
|
||||
MainController,
|
||||
UsersController,
|
||||
IssuesController,
|
||||
CalendarController,
|
||||
DashboardController,
|
||||
DashboardsController,
|
||||
],
|
||||
controllers: [MainController, UsersController, IssuesController, CalendarController],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
28
libs/event-emitter/src/models/dashboard.ts
Normal file
28
libs/event-emitter/src/models/dashboard.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export type Data = {
|
||||
widgets: Widget[];
|
||||
title?: string;
|
||||
} | null;
|
||||
|
||||
export type Dashboard = {
|
||||
id: string;
|
||||
data: Data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Параметры для отрисовки данных
|
||||
*/
|
||||
export type WidgetParams = Record<string, any> | null;
|
||||
|
||||
/**
|
||||
* Параметры для загрузки данных
|
||||
*/
|
||||
export type DataLoaderParams = Record<string, any> | null;
|
||||
|
||||
export type Widget = {
|
||||
type: string;
|
||||
id: string;
|
||||
title: string;
|
||||
collapsed?: boolean;
|
||||
widgetParams?: WidgetParams;
|
||||
dataLoaderParams?: DataLoaderParams;
|
||||
};
|
||||
|
|
@ -16,6 +16,7 @@ export type MainConfigModel = {
|
|||
dbs: {
|
||||
users: string;
|
||||
issues: string;
|
||||
dashboards: string;
|
||||
};
|
||||
};
|
||||
webhooks: WebhookConfigItemModel[];
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedmineTypes } from '../models/redmine-types';
|
||||
|
||||
export namespace ProjectDashboard {
|
||||
export namespace Models {
|
||||
export type Params = {
|
||||
projectName: string;
|
||||
workers: Worker[];
|
||||
filter: FilterDefination[];
|
||||
statuses: Status[];
|
||||
};
|
||||
|
||||
export type Worker = {
|
||||
id?: number;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export enum FilterTypes {
|
||||
TREE = 'TREE',
|
||||
LIST = 'LIST',
|
||||
DYNAMIC_LIST = 'DYNAMIC_LIST',
|
||||
VERSION = 'VERSION',
|
||||
}
|
||||
|
||||
export namespace FilterParams {
|
||||
export type Tree = {
|
||||
rootIssueId: number;
|
||||
subtrees: SubTree[];
|
||||
showOthers: boolean;
|
||||
};
|
||||
export type SubTree = {
|
||||
title: string;
|
||||
issueId: number;
|
||||
};
|
||||
export type List = {
|
||||
issueIds: number[];
|
||||
};
|
||||
export type Version = {
|
||||
version: string;
|
||||
};
|
||||
export type DynamicList = {
|
||||
selector: Record<string, any>;
|
||||
};
|
||||
export type AnyFilterParams = Tree | List | DynamicList | Version;
|
||||
}
|
||||
|
||||
export type FilterParams = Record<string, any>;
|
||||
|
||||
export namespace FilterResults {
|
||||
export type Tree = List;
|
||||
export type List = { status: string; issues: RedmineTypes.Issue[] }[];
|
||||
export type DynamicList = List;
|
||||
export type Version = List;
|
||||
export type AnyFilterResults = List | Tree | DynamicList | Version;
|
||||
}
|
||||
|
||||
export type FilterResult = Record<string, any>[];
|
||||
|
||||
export type AnalyticFunction = {
|
||||
functionName: string;
|
||||
};
|
||||
|
||||
export type FilterDefination = {
|
||||
type: FilterTypes;
|
||||
title: string;
|
||||
params: FilterParams.AnyFilterParams;
|
||||
};
|
||||
|
||||
export type FilterWithResults = {
|
||||
params: FilterDefination;
|
||||
results: FilterResults.AnyFilterResults;
|
||||
};
|
||||
|
||||
export type Status = {
|
||||
name: string;
|
||||
closed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function CheckWorker(worker: Models.Worker): boolean {
|
||||
return Boolean(
|
||||
(typeof worker.id === 'number' && worker.id >= 0) ||
|
||||
(worker.firstname && worker.lastname) ||
|
||||
worker.name,
|
||||
);
|
||||
}
|
||||
|
||||
export class SingleProject {
|
||||
// TODO: code for SingleProject
|
||||
constructor(private params: Models.Params) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Widgets {
|
||||
// Чё будет делать виджет?
|
||||
// * рендер - из параметров будет создавать данные с какими-либо расчётами
|
||||
|
||||
export interface Widget {
|
||||
render(
|
||||
filterParams: Models.FilterParams,
|
||||
dashboardParams: Models.Params,
|
||||
): Models.FilterResult;
|
||||
}
|
||||
|
||||
export class List implements Widget {
|
||||
render(
|
||||
filterParams: Models.FilterParams,
|
||||
dashboardParams: Models.Params,
|
||||
): Models.FilterResult {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
export class DynamicList implements Widget {
|
||||
render(
|
||||
filterParams: Models.FilterParams,
|
||||
dashboardParams: Models.Params,
|
||||
): Models.FilterResult {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
export class Tree implements Widget {
|
||||
render(
|
||||
filterParams: Models.FilterParams,
|
||||
dashboardParams: Models.Params,
|
||||
): Models.FilterResult {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
export class Version implements Widget {
|
||||
render(
|
||||
filterParams: Models.FilterParams,
|
||||
dashboardParams: Models.Params,
|
||||
): Models.FilterResult {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProjectDashboardService {
|
||||
constructor() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export interface WidgetInterface<W, D, R> {
|
||||
isMyConfig(widgetParams: W): boolean;
|
||||
render(widgetParams: W, dashboardParams: D): Promise<R>;
|
||||
}
|
||||
74
libs/event-emitter/src/utils/result.ts
Normal file
74
libs/event-emitter/src/utils/result.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export type Result<T, E> = {
|
||||
result?: T;
|
||||
error?: E;
|
||||
};
|
||||
|
||||
export function success<T, E>(res: T): Result<T, E> {
|
||||
return {
|
||||
result: res,
|
||||
};
|
||||
}
|
||||
|
||||
export function fail<T, E>(error: E): Result<T, E> {
|
||||
return {
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrThrow<T, E>(res: Result<T, E>): T {
|
||||
if (res.result) return res.result;
|
||||
throw res.error ? res.error : 'UNKNOWN_ERROR';
|
||||
}
|
||||
|
||||
export async function successOrError<T, E>(
|
||||
cb: () => Promise<T>,
|
||||
): Promise<Result<T, E>> {
|
||||
try {
|
||||
const res = await cb();
|
||||
return {
|
||||
result: res,
|
||||
};
|
||||
} catch (ex) {
|
||||
return {
|
||||
error: ex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AppError = Error & {
|
||||
app: true;
|
||||
};
|
||||
|
||||
export function createAppError(msg: string | Error): AppError {
|
||||
let err: any;
|
||||
if (typeof msg === 'string') {
|
||||
err = new Error(msg);
|
||||
} else if (typeof msg === 'object') {
|
||||
err = msg;
|
||||
} else {
|
||||
err = new Error('UNKNOWN_APP_ERROR');
|
||||
}
|
||||
err.name = 'ApplicationError';
|
||||
return err;
|
||||
}
|
||||
|
||||
export async function getOrAppErrorOrThrow<T>(
|
||||
fn: () => Promise<T>,
|
||||
onAppError?: (err: Error) => Error,
|
||||
onOtherError?: (err: Error) => Error,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (ex) {
|
||||
if (ex && ex.name === 'ApplicationError') {
|
||||
throw onAppError ? onAppError(ex) : ex;
|
||||
}
|
||||
throw onOtherError ? onOtherError(ex) : ex;
|
||||
}
|
||||
}
|
||||
|
||||
export function BadRequestErrorHandler(err: Error): Error {
|
||||
return new BadRequestException(err.message);
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handler
|
|||
import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service';
|
||||
import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller';
|
||||
import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer';
|
||||
import { IssuesByTagsWidgetService } from './dashboards/widgets/issues-by-tags.widget.service';
|
||||
import { IssuesByTagsWidgetDataLoaderService } from './dashboards/widget-data-loader/issues-by-tags.widget-data-loader.service';
|
||||
import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to-tags-enhancer';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
|
|
@ -51,6 +51,8 @@ import { SimpleIssuesListController } from './dashboards/simple-issues-list.cont
|
|||
import { TagsManagerController } from './tags-manager/tags-manager.controller';
|
||||
import { CreateTagManagerServiceProvider } from './tags-manager/tags-manager.service';
|
||||
import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-enhancer';
|
||||
import { Dashboards as DashboardsDs } from '@app/event-emitter/couchdb-datasources/dashboards';
|
||||
import { DashboardInitService } from './dashboards/dashboard-init.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -98,7 +100,7 @@ import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-en
|
|||
DailyEccmUserCommentsService,
|
||||
SetDailyEccmUserCommentBotHandlerService,
|
||||
DailyEccmWithExtraDataService,
|
||||
IssuesByTagsWidgetService,
|
||||
IssuesByTagsWidgetDataLoaderService,
|
||||
{
|
||||
provide: 'CATEGORY_MERGE_TO_TAGS_ENHANCER',
|
||||
useFactory: (configService: ConfigService) => {
|
||||
|
|
@ -110,6 +112,7 @@ import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-en
|
|||
inject: [ConfigService],
|
||||
},
|
||||
CreateTagManagerServiceProvider('TAG_MANAGER_SERVICE'),
|
||||
DashboardInitService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnModuleInit {
|
||||
|
|
@ -136,6 +139,8 @@ export class AppModule implements OnModuleInit {
|
|||
|
||||
@Inject('CALENDAR_ENHANCER')
|
||||
private calendarEnhancer: CalendarEnhancer,
|
||||
|
||||
private dashboardInitService: DashboardInitService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
|
|
@ -145,6 +150,7 @@ export class AppModule implements OnModuleInit {
|
|||
UserMetaInfo.getDatasource();
|
||||
DailyEccmReportsDatasource.getDatasource();
|
||||
DailyEccmReportsUserCommentsDatasource.getDatasource();
|
||||
DashboardsDs.getDatasource();
|
||||
|
||||
this.enhancerService.addEnhancer([
|
||||
this.timestampEnhancer,
|
||||
|
|
@ -213,6 +219,7 @@ export class AppModule implements OnModuleInit {
|
|||
});
|
||||
|
||||
this.initDailyEccmUserCommentsPipeline();
|
||||
this.initDashbordProviders();
|
||||
}
|
||||
|
||||
private initDailyEccmUserCommentsPipeline(): void {
|
||||
|
|
@ -226,4 +233,8 @@ export class AppModule implements OnModuleInit {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
private initDashbordProviders(): void {
|
||||
this.dashboardInitService.init();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/dashboards/dashboard-init.service.ts
Normal file
26
src/dashboards/dashboard-init.service.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { WidgetsCollectionService } from '@app/event-emitter/dashboards/widgets-collection.service';
|
||||
import { IssuesByTagsWidgetDataLoaderService } from './widget-data-loader/issues-by-tags.widget-data-loader.service';
|
||||
import { createInteractiveWidget } from '@app/event-emitter/dashboards/interactive-widget-factory';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardInitService {
|
||||
constructor(
|
||||
private issuesByTagsWidgetDataLoaderService: IssuesByTagsWidgetDataLoaderService,
|
||||
private widgetsCollectionService: WidgetsCollectionService,
|
||||
) {}
|
||||
|
||||
init(): void {
|
||||
const collection = [
|
||||
createInteractiveWidget(
|
||||
this.issuesByTagsWidgetDataLoaderService,
|
||||
'kanban_by_tags',
|
||||
),
|
||||
createInteractiveWidget(
|
||||
this.issuesByTagsWidgetDataLoaderService,
|
||||
'issues_list_by_tags',
|
||||
),
|
||||
];
|
||||
collection.forEach((w) => this.widgetsCollectionService.appendWidget(w));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader';
|
||||
import { Controller, Get, Param, Render } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IssuesByTagsWidgetService } from './widgets/issues-by-tags.widget.service';
|
||||
import { IssuesByTagsWidgetDataLoaderService } from './widget-data-loader/issues-by-tags.widget-data-loader.service';
|
||||
import { parse } from 'jsonc-parser';
|
||||
|
||||
@Controller('simple-issues-list')
|
||||
|
|
@ -9,7 +9,7 @@ export class SimpleIssuesListController {
|
|||
private path: string;
|
||||
|
||||
constructor(
|
||||
private issuesByTagsWidgetService: IssuesByTagsWidgetService,
|
||||
private issuesByTagsWidgetDataLoaderService: IssuesByTagsWidgetDataLoaderService,
|
||||
private dynamicLoader: DynamicLoader,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
|
|
@ -23,7 +23,7 @@ export class SimpleIssuesListController {
|
|||
ext: 'jsonc',
|
||||
parser: parse,
|
||||
});
|
||||
return await this.issuesByTagsWidgetService.render(cfg);
|
||||
return await this.issuesByTagsWidgetDataLoaderService.load(cfg);
|
||||
}
|
||||
|
||||
@Get('/by-tags/:name')
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader';
|
||||
import { RedmineEventsGateway } from '@app/event-emitter/events/redmine-events.gateway';
|
||||
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
||||
import { ListIssuesByFieldsWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-fields.widget.service';
|
||||
import { ListIssuesByUsersLikeJiraWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service';
|
||||
import { ListIssuesByUsersWidgetService } from '@app/event-emitter/project-dashboard/widgets/list-issues-by-users.widget.service';
|
||||
import {
|
||||
RootIssueSubTreesWidgetNs,
|
||||
RootIssueSubTreesWidgetService,
|
||||
} from '@app/event-emitter/project-dashboard/widgets/root-issue-subtrees.widget.service';
|
||||
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||
import { Controller, Get, Logger, Param, Render } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { parse } from 'jsonc-parser';
|
||||
import { IssuesByTagsWidgetService } from './widgets/issues-by-tags.widget.service';
|
||||
import { IssuesByTagsWidgetDataLoaderService } from './widget-data-loader/issues-by-tags.widget-data-loader.service';
|
||||
import { RootIssueSubTreesWidgetDataLoaderService } from '@app/event-emitter/dashboards/widget-data-loader/root-issue-subtrees.widget-data-loader.service';
|
||||
import { ListIssuesByUsersWidgetDataLoaderService } from '@app/event-emitter/dashboards/widget-data-loader/list-issues-by-users.widget-data-loader.service';
|
||||
import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from '@app/event-emitter/dashboards/widget-data-loader/list-issues-by-users-like-jira.widget-data-loader.service';
|
||||
import { ListIssuesByFieldsWidgetDataLoaderService } from '@app/event-emitter/dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service';
|
||||
|
||||
@Controller('simple-kanban-board')
|
||||
export class SimpleKanbanBoardController {
|
||||
|
|
@ -20,14 +16,14 @@ export class SimpleKanbanBoardController {
|
|||
private path: string;
|
||||
|
||||
constructor(
|
||||
private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService,
|
||||
private rootIssueSubTreesWidgetDataLoaderService: RootIssueSubTreesWidgetDataLoaderService,
|
||||
private dynamicLoader: DynamicLoader,
|
||||
private configService: ConfigService,
|
||||
private listIssuesByUsersWidgetService: ListIssuesByUsersWidgetService,
|
||||
private listIssuesByUsersLikeJiraWidgetService: ListIssuesByUsersLikeJiraWidgetService,
|
||||
private issuesByTagsWidgetService: IssuesByTagsWidgetService,
|
||||
private listIssuesByUsersWidgetDataLoaderService: ListIssuesByUsersWidgetDataLoaderService,
|
||||
private listIssuesByUsersLikeJiraWidgetDataLoaderService: ListIssuesByUsersLikeJiraWidgetDataLoaderService,
|
||||
private issuesByTagsWidgetDataLoaderService: IssuesByTagsWidgetDataLoaderService,
|
||||
private redmineEventsGateway: RedmineEventsGateway,
|
||||
private listIssuesByFieldsWidgetService: ListIssuesByFieldsWidgetService,
|
||||
private listIssuesByFieldsWidgetDataLoaderService: ListIssuesByFieldsWidgetDataLoaderService,
|
||||
private issuesService: IssuesService,
|
||||
) {
|
||||
this.path = this.configService.get<string>('simpleKanbanBoard.path');
|
||||
|
|
@ -40,7 +36,7 @@ export class SimpleKanbanBoardController {
|
|||
ext: 'jsonc',
|
||||
parser: parse,
|
||||
});
|
||||
return await this.rootIssueSubTreesWidgetService.render(cfg);
|
||||
return await this.rootIssueSubTreesWidgetDataLoaderService.load(cfg);
|
||||
}
|
||||
|
||||
@Get('/tree/:name')
|
||||
|
|
@ -73,7 +69,7 @@ export class SimpleKanbanBoardController {
|
|||
ext: 'jsonc',
|
||||
parser: parse,
|
||||
});
|
||||
return await this.listIssuesByUsersWidgetService.render(cfg);
|
||||
return await this.listIssuesByUsersWidgetDataLoaderService.load(cfg);
|
||||
}
|
||||
|
||||
@Get('/by-users/:name')
|
||||
|
|
@ -89,7 +85,9 @@ export class SimpleKanbanBoardController {
|
|||
ext: 'jsonc',
|
||||
parser: parse,
|
||||
});
|
||||
return await this.listIssuesByUsersLikeJiraWidgetService.render(cfg);
|
||||
return await this.listIssuesByUsersLikeJiraWidgetDataLoaderService.load(
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/by-users-like-jira/:name')
|
||||
|
|
@ -105,7 +103,7 @@ export class SimpleKanbanBoardController {
|
|||
ext: 'jsonc',
|
||||
parser: parse,
|
||||
});
|
||||
return await this.issuesByTagsWidgetService.render(cfg);
|
||||
return await this.issuesByTagsWidgetDataLoaderService.load(cfg);
|
||||
}
|
||||
|
||||
@Get('/by-tags/:name')
|
||||
|
|
@ -121,7 +119,7 @@ export class SimpleKanbanBoardController {
|
|||
ext: 'jsonc',
|
||||
parser: parse,
|
||||
});
|
||||
return await this.listIssuesByFieldsWidgetService.render(cfg);
|
||||
return await this.listIssuesByFieldsWidgetDataLoaderService.load(cfg);
|
||||
}
|
||||
|
||||
@Get('/by-fields/:name')
|
||||
|
|
|
|||
|
|
@ -6,13 +6,19 @@ import {
|
|||
IssuesService,
|
||||
IssuesServiceNs,
|
||||
} from '@app/event-emitter/issues/issues.service';
|
||||
import { WidgetInterface } from '@app/event-emitter/project-dashboard/widget-interface';
|
||||
import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store';
|
||||
import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key';
|
||||
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import nano from 'nano';
|
||||
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer';
|
||||
import { WidgetDataLoaderInterface } from '@app/event-emitter/dashboards/widget-data-loader-interface';
|
||||
import {
|
||||
AppError,
|
||||
Result,
|
||||
createAppError,
|
||||
success,
|
||||
} from '@app/event-emitter/utils/result';
|
||||
|
||||
export namespace IssuesByTagsWidgetNs {
|
||||
export type Params = {
|
||||
|
|
@ -27,10 +33,10 @@ export namespace IssuesByTagsWidgetNs {
|
|||
type Params = IssuesByTagsWidgetNs.Params;
|
||||
|
||||
@Injectable()
|
||||
export class IssuesByTagsWidgetService
|
||||
implements WidgetInterface<Params, any, any>
|
||||
export class IssuesByTagsWidgetDataLoaderService
|
||||
implements WidgetDataLoaderInterface<Params, any, any>
|
||||
{
|
||||
private logger = new Logger(IssuesByTagsWidgetService.name);
|
||||
private logger = new Logger(IssuesByTagsWidgetDataLoaderService.name);
|
||||
private issuesLoader: IssuesServiceNs.IssuesLoader;
|
||||
|
||||
constructor(
|
||||
|
|
@ -45,7 +51,7 @@ export class IssuesByTagsWidgetService
|
|||
return true;
|
||||
}
|
||||
|
||||
async render(widgetParams: Params): Promise<any> {
|
||||
async load(widgetParams: Params): Promise<Result<any, AppError>> {
|
||||
let store: FlatIssuesStore;
|
||||
if (widgetParams.fromRootIssueId) {
|
||||
store = await this.getListFromRoot(widgetParams.fromRootIssueId);
|
||||
|
|
@ -54,7 +60,7 @@ export class IssuesByTagsWidgetService
|
|||
} else {
|
||||
const errMsg = `Wrong widgetParams value`;
|
||||
this.logger.error(errMsg);
|
||||
throw new Error(errMsg);
|
||||
return fail(createAppError(errMsg));
|
||||
}
|
||||
await store.enhanceIssues([
|
||||
this.timePassedHighlightEnhancer,
|
||||
|
|
@ -100,7 +106,7 @@ export class IssuesByTagsWidgetService
|
|||
return a.metainfo.title.localeCompare(b.metainfo.title);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
return success(res);
|
||||
}
|
||||
|
||||
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {
|
||||
Loading…
Reference in a new issue