Merge branch 'feature/dashboards-editor' into dev

This commit is contained in:
Pavel Gnedov 2023-11-07 02:00:05 +07:00
commit 4cd0cf607e
76 changed files with 2603 additions and 1166 deletions

View file

@ -43,7 +43,8 @@
"url": "", "url": "",
"dbs": { "dbs": {
"users": "", "users": "",
"issues": "" "issues": "",
"dashboards": ""
} }
} }
} }

View file

@ -8,6 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.6.0",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.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", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" "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": { "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1", "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", "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" "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": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" "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": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "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", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" "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": { "@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1", "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", "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==", "integrity": "sha512-oe82BNgMr408e6DxMDNat8msXQTuyuqzJ97DPupbhchEfjjHyjsmPSwtXHl+nXiW3tybpb/cr5siUClBqKqv+Q==",
"requires": {} "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": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" "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": { "statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View file

@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.6.0",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",

24
frontend/src/.eslintrc.js Normal file
View 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',
},
};

View 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} />;
};

View 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);
}
}
}

View 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;
});

View 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>
);
});

View 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} />
</>
);
};

View 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);
}

View 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%;
}

View 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>
);
});

View 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>
);
});

View 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} />;
});

View 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} />;
});

View 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>
);
});

View file

@ -5,12 +5,12 @@ import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement,
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode> </React.StrictMode>,
); );
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function

View file

@ -1,19 +1,26 @@
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { IBoardStore } from './store'; import * as KanbanBoardStoreNs from '../kanban-board/store';
import Css from './issues-list-board.module.css'; import Css from './issues-list-board.module.css';
import * as IssuesListCardNs from './issues-list-card'; import * as IssuesListCardNs from './issues-list-card';
export type Props = { export type Props = {
store: IBoardStore store: KanbanBoardStoreNs.IBoardStore;
} };
export const IssuesListBoard = observer((props: Props): JSX.Element => { export const IssuesListBoard = observer((props: Props): JSX.Element => {
const list: JSX.Element[] = props.store.data.map((issue) => { const list: JSX.Element[] = [];
return ( const data = props.store.data;
<IssuesListCardNs.IssuesListCard store={issue} key={issue.id}/> 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; let title: JSX.Element;
if (props.store.metainfo.url) { if (props.store.metainfo.url) {
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>; 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 ( return (
<div className={Css.board}> <div className={Css.board}>
<div className={Css.boardName}> <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}`}> <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> </a>
</div> </div>
<div className={Css.listContainer}> <div className={Css.listContainer}>{list}</div>
{list}
</div>
</div> </div>
); );
}); });

View file

@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom'; 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'; import * as IssuesListBoardsNs from './issues-list-boards';
export const IssuesListBoardPage = (): JSX.Element => { export const IssuesListBoardPage = (): JSX.Element => {
@ -15,10 +15,12 @@ export const IssuesListBoardPage = (): JSX.Element => {
}); });
// DEBUG: end // DEBUG: end
const store = IssuesListStoreNs.PageStore.create({loaded: false, type: type, name: name}); const store = KanbanBoardStoreNs.PageStore.create({
IssuesListStoreNs.PageStoreLoadData(store); loaded: false,
type: type,
name: name,
});
KanbanBoardStoreNs.PageStoreLoadData(store);
return ( return <IssuesListBoardsNs.IssuesListBoards store={store} />;
<IssuesListBoardsNs.IssuesListBoards store={store}/>
);
}; };

View file

@ -1,32 +1,34 @@
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React from 'react'; 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 IssuesListBoardNs from './issues-list-board';
import * as TopRightMenuNs from '../misc-components/top-right-menu'; import * as TopRightMenuNs from '../misc-components/top-right-menu';
import { SetIssuesReadingTimestamp } from '../utils/unreaded-provider'; import { SetIssuesReadingTimestamp } from '../utils/unreaded-provider';
import * as ServiceActionsButtons from '../utils/service-actions-buttons'; import * as ServiceActionsButtons from '../utils/service-actions-buttons';
export type Props = { export type Props = {
store: IssuesListBoardStore.IPageStore store: KanbanBoardStoreNs.IPageStore;
}; };
export const IssuesListBoards = observer((props: Props): JSX.Element => { export const IssuesListBoards = observer((props: Props): JSX.Element => {
const data = props.store.data; const data = props.store.data;
if (!props.store.loaded || !data) { if (!props.store.loaded || !data) {
return <div>Loading...</div> return <div>Loading...</div>;
} }
const list: any[] = []; const list: any[] = [];
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const boardData = data[i]; const boardData = data[i];
const key = boardData.metainfo.title; const key = boardData.metainfo.title;
const board = <IssuesListBoardNs.IssuesListBoard store={boardData} key={key}/> const board = (
<IssuesListBoardNs.IssuesListBoard store={boardData} key={key} />
);
list.push(board); list.push(board);
} }
const topRightMenuStore = TopRightMenuNs.Store.create({visible: false}); const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false });
const onAllReadItemClick = (e: React.MouseEvent) => { const onAllReadItemClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
SetIssuesReadingTimestamp(props.store.issueIds); SetIssuesReadingTimestamp(props.store.issueIds);
IssuesListBoardStore.PageStoreLoadData(props.store); KanbanBoardStoreNs.PageStoreLoadData(props.store);
}; };
return ( return (
<> <>

View file

@ -11,7 +11,7 @@ import { SpentHoursToFixed } from '../utils/spent-hours-to-fixed';
import { getStyleObjectFromString } from '../utils/style'; import { getStyleObjectFromString } from '../utils/style';
export type Props = { export type Props = {
store: IIssueStore store: IIssueStore;
}; };
export const defaultPriorityStyleKey = 'priorityStyle'; export const defaultPriorityStyleKey = 'priorityStyle';
@ -21,15 +21,28 @@ export const IssuesListCard = observer((props: Props): JSX.Element => {
const detailsStore = IssueDetailsDialogNs.Store.create({ const detailsStore = IssueDetailsDialogNs.Store.create({
issue: props.store, issue: props.store,
visible: false, visible: false,
unreadedFlagStore: unreadedStore unreadedFlagStore: unreadedStore,
}); });
const priorityStyle = getStyleObjectFromString(props.store[defaultPriorityStyleKey]); const priorityStyle = getStyleObjectFromString(
const tagsNewLine = (props.store.styledTags && props.store.styledTags.length > 0) ? <br/> : null; props.store[defaultPriorityStyleKey],
);
const tagsNewLine =
props.store.styledTags && props.store.styledTags.length > 0 ? <br /> : null;
return ( 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} /> <IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
<div className={Css.relevanceColor}> <div className={Css.relevanceColor}>
<TimePassedNs.TimePassed params={{ fromIssue: { issue: props.store, keyName: 'timePassedClass' } }} /> <TimePassedNs.TimePassed
params={{
fromIssue: { issue: props.store, keyName: 'timePassedClass' },
}}
/>
</div> </div>
<div className={Css.importantInformation}> <div className={Css.importantInformation}>
<span className={Css.issueSubject}> <span className={Css.issueSubject}>
@ -41,12 +54,18 @@ export const IssuesListCard = observer((props: Props): JSX.Element => {
/> />
</span> </span>
<span> </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} {tagsNewLine}
<TagsNs.Tags params={{ tags: props.store.styledTags }} /> <TagsNs.Tags params={{ tags: props.store.styledTags }} />
<div className={Css.positionInfo}> <div className={Css.positionInfo}>
<span className={Css.timeBox}>{props.store.status.name}</span><span> </span> <span className={Css.timeBox}>{props.store.status.name}</span>
<span className={Css.priorityBox} style={priorityStyle}>{props.store.priority.name}</span> <span> </span>
<span className={Css.priorityBox} style={priorityStyle}>
{props.store?.priority?.name}
</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,46 +1,49 @@
import { Instance, types } from "mobx-state-tree"; import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from "../redmine-types"; import { RedmineTypes } from '../redmine-types';
import axios from "axios"; 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 MetaInfoStore = types.model({ export const MetaInfoStore = types.model({
title: types.string, title: types.string,
url: types.maybe(types.string), url: types.maybe(types.string),
rootIssue: types.maybe(types.model({ rootIssue: types.maybe(
types.model({
id: 0, id: 0,
tracker: types.model({ tracker: types.model({
id: 0, id: 0,
name: '' name: '',
}), }),
subject: '' subject: '',
})) }),
),
}); });
export const BoardStore = types.model({ export const BoardStore = types.model({
data: types.array(IssueStore), 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({ export const PageStore = types
loaded: types.boolean, .model({
type: types.string, loaded: false,
name: types.string, type: '',
data: types.maybeNull( name: '',
types.array(BoardStore) data: types.maybeNull(types.array(BoardStore)),
) })
}).actions((self) => { .actions((self) => {
return { return {
setData: (data: any) => { setData: (data: any) => {
self.data = data; self.data = data;
self.loaded = true; self.loaded = true;
} },
}; };
}).views((self) => { })
.views((self) => {
return { return {
get issueIds(): number[] { get issueIds(): number[] {
if (!self.data) return []; if (!self.data) return [];
@ -56,23 +59,27 @@ export const PageStore = types.model({
} }
} }
return res; return res;
} },
}; };
}); });
export async function PageStoreLoadData(store: IPageStore): Promise<void> { export async function PageStoreLoadData(store: IPageStore): Promise<void> {
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`; const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url); const resp = await axios.get(url);
if (!(resp?.data)) return; if (!resp?.data) return;
const data = []; const data = [];
for (let i = 0; i < resp.data.length; i++) { 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({ data.push({
metainfo: item.metainfo, metainfo: item.metainfo,
data: item.data ? item.data.map((group: { status: string, count: number, issues: any[] }) => { data: item.data
return group.issues ? item.data
}).flat() : [] .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); store.setData(data);
} }
export interface IPageStore extends Instance<typeof PageStore> {} export type IPageStore = Instance<typeof PageStore>;

View file

@ -5,13 +5,16 @@ import { observer } from 'mobx-react-lite';
import * as KanbanCard from './kanban-card'; import * as KanbanCard from './kanban-card';
export type Props = { export type Props = {
store: Stores.IColumnStore store: Stores.IColumnStore;
} };
export const Column = observer((props: Props) => { export const Column = observer((props: Props) => {
const cards = props.store.cards.map((card) => { const cards = props.store.cards.map((card) => {
return ( return (
<KanbanCard.KanbanCard store={card} key={card.issue.id}></KanbanCard.KanbanCard> <KanbanCard.KanbanCard
store={card}
key={card.issue.id}
></KanbanCard.KanbanCard>
); );
}); });
return ( return (

View file

@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
import Column from './column'; import Column from './column';
export type Props = { export type Props = {
store: IBoardStore store: IBoardStore;
}; };
export const KanbanBoard = observer((props: Props) => { export const KanbanBoard = observer((props: Props) => {
@ -18,14 +18,14 @@ export const KanbanBoard = observer((props: Props) => {
const columns = []; const columns = [];
for (let i = 0; i < props.store.data.length; i++) { for (let i = 0; i < props.store.data.length; i++) {
const column = props.store.data[i]; const column = props.store.data[i];
columns.push(<Column store={column}/>) columns.push(<Column store={column} />);
} }
return ( return (
<> <>
<h1 id={props.store.metainfo.title}>{title} <a href={`#${props.store.metainfo.title}`}>#</a></h1> <h1 id={props.store.metainfo.title}>
<div className={KanbanBoardCss.kanbanContainer}> {title} <a href={`#${props.store.metainfo.title}`}>#</a>
{columns} </h1>
</div> <div className={KanbanBoardCss.kanbanContainer}>{columns}</div>
</> </>
); );
}); });

View file

@ -15,8 +15,13 @@ export const KanbanBoardsPage = (): JSX.Element => {
}); });
// DEBUG: end // 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); Stores.PageStoreLoadData(store);
return <KBS.KanbanBoards store={store}/>; return <KBS.KanbanBoards store={store} />;
} };

View file

@ -8,13 +8,13 @@ import axios from 'axios';
import * as ServiceActionsButtons from '../utils/service-actions-buttons'; import * as ServiceActionsButtons from '../utils/service-actions-buttons';
export type Props = { export type Props = {
store: IPageStore store: IPageStore;
} };
export const KanbanBoards = observer((props: Props) => { export const KanbanBoards = observer((props: Props) => {
const data = props.store.data; const data = props.store.data;
if (!props.store.loaded || !data) { if (!props.store.loaded || !data) {
return <div>Loading...</div> return <div>Loading...</div>;
} }
const list: any[] = []; const list: any[] = [];
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
@ -23,7 +23,7 @@ export const KanbanBoards = observer((props: Props) => {
const board = <KB.KanbanBoard store={boardData} key={key} />; const board = <KB.KanbanBoard store={boardData} key={key} />;
list.push(board); list.push(board);
} }
const topRightMenuStore = TopRightMenuNs.Store.create({visible: false}); const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false });
const onAllReadClick = (e: React.MouseEvent) => { const onAllReadClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
SetIssuesReadingTimestamp(props.store.issueIds); SetIssuesReadingTimestamp(props.store.issueIds);
@ -34,17 +34,21 @@ export const KanbanBoards = observer((props: Props) => {
const onTreeRefreshClick = (e: React.MouseEvent) => { const onTreeRefreshClick = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return; if (e.target !== e.currentTarget) return;
e.stopPropagation(); e.stopPropagation();
axios.get(`${process.env.REACT_APP_BACKEND}simple-kanban-board/tree/${props.store.name}/refresh`); axios.get(
} `${process.env.REACT_APP_BACKEND}simple-kanban-board/tree/${props.store.name}/refresh`,
treeRefreshMenuItem = <button onClick={onTreeRefreshClick}>Force tree refresh</button>; );
};
treeRefreshMenuItem = (
<button onClick={onTreeRefreshClick}>Force tree refresh</button>
);
} }
return ( return (
<> <>
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}> <TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
<button onClick={onAllReadClick}>Всё прочитано</button> <button onClick={onAllReadClick}>Всё прочитано</button>
{treeRefreshMenuItem} {treeRefreshMenuItem}
<ServiceActionsButtons.IssuesForceRefreshButton/> <ServiceActionsButtons.IssuesForceRefreshButton />
<ServiceActionsButtons.GetIssuesQueueSizeButton/> <ServiceActionsButtons.GetIssuesQueueSizeButton />
</TopRightMenuNs.TopRightMenu> </TopRightMenuNs.TopRightMenu>
{list} {list}
</> </>

View file

@ -9,7 +9,7 @@ import * as IssueDetailsDialogNs from '../misc-components/issue-details-dialog';
import * as UnreadedFlagNs from '../misc-components/unreaded-flag'; import * as UnreadedFlagNs from '../misc-components/unreaded-flag';
export type Props = { export type Props = {
store: ICardStore store: ICardStore;
}; };
export type TagProps = { export type TagProps = {
@ -24,7 +24,7 @@ export const KanbanCardTag = (props: TagProps): JSX.Element => {
{props.tag} {props.tag}
</span> </span>
); );
} };
/** /**
* Какие дальше требования к карточкам? * Какие дальше требования к карточкам?
@ -43,33 +43,47 @@ export const KanbanCard = observer((props: Props) => {
if (tagsParams && props.store.issue[tagsParams.path]) { if (tagsParams && props.store.issue[tagsParams.path]) {
const tags = props.store.issue[tagsParams.path] as TagProps[]; const tags = props.store.issue[tagsParams.path] as TagProps[];
console.debug(`Tags:`, tags); // DEBUG console.debug(`Tags:`, tags); // DEBUG
tagsSection = <TagsNs.Tags params={{tags: tags}}/> tagsSection = <TagsNs.Tags params={{ tags: tags }} />;
} }
const timePassedParams: TimePassedNs.Params = { const timePassedParams: TimePassedNs.Params = {
fromIssue: { fromIssue: {
issue: props.store.issue, issue: props.store.issue,
keyName: 'timePassedClass' keyName: 'timePassedClass',
} },
} };
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store.issue); const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(
props.store.issue,
);
const detailsStore = IssueDetailsDialogNs.Store.create({ const detailsStore = IssueDetailsDialogNs.Store.create({
issue: props.store.issue, issue: props.store.issue,
visible: false, visible: false,
unreadedFlagStore: unreadedStore unreadedFlagStore: unreadedStore,
}); });
return ( 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} /> <IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
<div className={KanbanCardCss.kanbanCardTitle}> <div className={KanbanCardCss.kanbanCardTitle}>
<UnreadedFlagNs.UnreadedFlag store={unreadedStore}/> <UnreadedFlagNs.UnreadedFlag store={unreadedStore} />
<TimePassedNs.TimePassed params={timePassedParams}/> <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>
<div>Исп.: {props.store.issue.current_user.name}</div> <div>Исп.: {props.store.issue.current_user.name}</div>
<div>Прио.: {props.store.issue.priority.name}</div> <div>Прио.: {props.store.issue.priority.name}</div>
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div> <div>Версия: {props.store.issue.fixed_version?.name || ''}</div>
<div>Прогресс: {props.store.issue.done_ratio}</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} {tagsSection}
</div> </div>
); );

View file

@ -2,65 +2,71 @@ import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from '../redmine-types'; import { RedmineTypes } from '../redmine-types';
import axios from 'axios'; 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: '', status: '',
count: 0, count: 0,
issues: types.array(IssueStore) issues: types.array(IssueStore),
}).views((self) => { })
.views((self) => {
return { return {
get cards(): ICardStore[] { get cards(): ICardStore[] {
return self.issues.map(issue => { return self.issues.map((issue) => {
return CardStore.create({ 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({ export const MetaInfoStore = types.model({
title: '', title: '',
url: types.maybe(types.string), url: types.maybe(types.string),
rootIssue: types.maybe(types.model({ rootIssue: types.maybe(
types.model({
id: 0, id: 0,
tracker: types.model({ tracker: types.model({
id: 0, id: 0,
name: '' name: '',
}), }),
subject: '' subject: '',
})) }),
),
}); });
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {} export type IMetaInfoStore = Instance<typeof MetaInfoStore>;
export const BoardStore = types.model({ export const BoardStore = types.model({
data: types.array(ColumnStore), 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, loaded: false,
type: '', type: '',
name: '', name: '',
data: types.maybeNull( data: types.maybeNull(types.array(BoardStore)),
types.array(BoardStore) })
) .actions((self) => {
}).actions(self => {
return { return {
setData: (data: any) => { setData: (data: any) => {
console.debug('Kanban page store new data -', data); // DEBUG
self.data = data; self.data = data;
self.loaded = true; self.loaded = true;
} },
}; };
}).views((self) => { })
.views((self) => {
return { return {
get issueIds(): number[] { get issueIds(): number[] {
if (!self.data) return []; if (!self.data) return [];
@ -80,19 +86,19 @@ export const PageStore = types.model({
return res; return res;
}, },
get canTreeRefresh(): boolean { get canTreeRefresh(): boolean {
return (self.type === 'tree'); return self.type === 'tree';
} },
}; };
}); });
export async function PageStoreLoadData(store: IPageStore): Promise<void> { export async function PageStoreLoadData(store: IPageStore): Promise<void> {
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`; const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url); const resp = await axios.get(url);
if (!(resp?.data)) return; if (!resp?.data) return;
store.setData(resp.data); store.setData(resp.data);
} }
export interface IPageStore extends Instance<typeof PageStore> { } export type IPageStore = Instance<typeof PageStore>;
export type CardField = { export type CardField = {
component: string; component: string;
@ -100,10 +106,8 @@ export type CardField = {
export const CardParamsStore = types.optional( export const CardParamsStore = types.optional(
types.model({ types.model({
fields: types.array( fields: types.array(types.frozen<CardField>()),
types.frozen<CardField>() autoCollapse: types.boolean,
),
autoCollapse: types.boolean
}), }),
{ {
fields: [ fields: [
@ -112,16 +116,15 @@ export const CardParamsStore = types.optional(
{ component: 'text', label: 'Версия', path: 'fixed_version.name' }, { component: 'text', label: 'Версия', path: 'fixed_version.name' },
{ component: 'text', label: 'Прогресс', path: 'done_ratio' }, { component: 'text', label: 'Прогресс', path: 'done_ratio' },
{ component: 'labor_costs' }, { component: 'labor_costs' },
{ component: 'tags', label: 'Tags', path: 'styledTags' } { component: 'tags', label: 'Tags', path: 'styledTags' },
], ],
autoCollapse: false, autoCollapse: false,
} },
); );
export const CardStore = types.model({ export const CardStore = types.model({
issue: IssueStore, issue: IssueStore,
params: CardParamsStore params: CardParamsStore,
}); });
export interface ICardStore extends Instance<typeof CardStore> {} export type ICardStore = Instance<typeof CardStore>;

View file

@ -0,0 +1,5 @@
.debugInfo {
margin: 3px;
padding: 3px;
border: 1px solid black;
}

View 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>;
};

View file

@ -9,11 +9,13 @@ import { SetIssueReadingTimestamp } from '../utils/unreaded-provider';
import axios from 'axios'; import axios from 'axios';
import * as Luxon from 'luxon'; import * as Luxon from 'luxon';
export const Store = types.model({ export const Store = types
.model({
visible: types.boolean, visible: types.boolean,
issue: types.frozen<RedmineTypes.ExtendedIssue>(), issue: types.frozen<RedmineTypes.ExtendedIssue>(),
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store) unreadedFlagStore: types.maybe(UnreadedFlagNs.Store),
}).actions((self) => { })
.actions((self) => {
return { return {
hide: () => { hide: () => {
console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG
@ -27,18 +29,19 @@ export const Store = types.model({
} else { } else {
SetIssueReadingTimestamp(self.issue.id); SetIssueReadingTimestamp(self.issue.id);
} }
} },
}; };
}).views((self) => { })
.views((self) => {
return { return {
get displayStyle(): React.CSSProperties { get displayStyle(): React.CSSProperties {
return {display: self.visible ? 'block' : 'none'}; return { display: self.visible ? 'block' : 'none' };
} },
}; };
}); });
export type Props = { export type Props = {
store: Instance<typeof Store> store: Instance<typeof Store>;
}; };
export const IssueDetailsDialog = observer((props: Props): JSX.Element => { export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
@ -53,7 +56,11 @@ export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
}; };
return ( return (
<div className={Css.reset}> <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}> <div className={Css.modalContent}>
<h1> <h1>
<button onClick={onCloseClick}>close</button> <button onClick={onCloseClick}>close</button>
@ -65,17 +72,18 @@ export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
url={props.store.issue?.url?.url || ''} url={props.store.issue?.url?.url || ''}
/> />
</h1> </h1>
<hr/> <hr />
<div> <div>
<h2>Описание:</h2> <h2>Описание:</h2>
<pre> <pre>{props.store.issue.description}</pre>
{props.store.issue.description}
</pre>
</div> </div>
<hr/> <hr />
<div> <div>
<h2>Комментарии:</h2> <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> </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) => { const comments = props.details?.filter((detail) => {
return Boolean(detail.notes); return Boolean(detail.notes);
}); });
if (!comments) { if (!comments) {
return <>No comments</> return <>No comments</>;
} }
const list = comments.map((detail) => { const list = comments.map((detail) => {
const key = `issueid_${props.issue.id}_commentid_${detail.id}`; const key = `issueid_${props.issue.id}_commentid_${detail.id}`;
return <Comment data={detail} key={key}/> return <Comment data={detail} key={key} />;
}); });
return ( return <>{list}</>;
<>{list}</> };
);
}
export const Comment = (props: {data: RedmineTypes.Journal}): JSX.Element => { 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 ( 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> <div>
<pre> <pre>{props.data.notes || '-'}</pre>
{props.data.notes || '-'}
</pre>
</div> </div>
<hr/> <hr />
</> </>
); );
} };

View file

@ -9,6 +9,8 @@ export type Props = {
export const IssueHref = (props: Props): JSX.Element => { export const IssueHref = (props: Props): JSX.Element => {
return ( return (
<a href={props.url}>{props.tracker} #{props.id} - {props.subject}</a> <a href={props.url}>
{props.tracker} #{props.id} - {props.subject}
</a>
); );
}; };

View file

@ -17,4 +17,4 @@ export const Tag = (props: Props): JSX.Element => {
</span> </span>
</> </>
); );
} };

View file

@ -7,21 +7,23 @@ export type Params = {
}; };
export type Props = { export type Props = {
params: Params params: Params;
}; };
export const Tags = (props: Props): JSX.Element => { export const Tags = (props: Props): JSX.Element => {
if (!props.params.tags) { if (!props.params.tags) {
return (<></>); return <></>;
} }
let label = props.params.label || ''; let label = props.params.label || '';
if (label) label = `${label}: `; if (label) label = `${label}: `;
const tags = props.params.tags.map((tag) => { const tags =
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag}/>; props.params.tags.map((tag) => {
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag} />;
}) || []; }) || [];
return ( return (
<> <>
{label}{tags} {label}
{tags}
</> </>
); );
} };

View file

@ -4,14 +4,14 @@ import { RedmineTypes } from '../redmine-types';
export type Params = { export type Params = {
fromIssue?: { fromIssue?: {
issue: RedmineTypes.ExtendedIssue, issue: RedmineTypes.ExtendedIssue;
keyName: string, keyName: string;
}, };
fromValue?: string fromValue?: string;
}; };
export type Props = { export type Props = {
params: Params params: Params;
}; };
export const TimePassed = (props: Props): JSX.Element => { export const TimePassed = (props: Props): JSX.Element => {
@ -21,13 +21,15 @@ export const TimePassed = (props: Props): JSX.Element => {
let timePassedClassName = ''; // TODO let timePassedClassName = ''; // TODO
if (props.params.fromIssue) { if (props.params.fromIssue) {
const { issue, keyName } = 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) { } else if (props.params.fromValue) {
timePassedClassName = `${Css.timepassedDot} ${getClassName(props.params.fromValue)}`; timePassedClassName = `${Css.timepassedDot} ${getClassName(
props.params.fromValue,
)}`;
} }
return ( return <span className={timePassedClassName}></span>;
<span className={timePassedClassName}></span>
);
}; };
function getClassName(value: string): string { function getClassName(value: string): string {

View file

@ -3,17 +3,20 @@ import { Instance, types } from 'mobx-state-tree';
import React from 'react'; import React from 'react';
import Css from './top-right-menu.module.css'; import Css from './top-right-menu.module.css';
export const Store = types.model({ export const Store = types
visible: types.boolean .model({
}).views((self) => { visible: types.boolean,
})
.views((self) => {
return { return {
get style(): React.CSSProperties { get style(): React.CSSProperties {
return { return {
display: self.visible ? 'block' : 'none' display: self.visible ? 'block' : 'none',
}; };
} },
}; };
}).actions((self) => { })
.actions((self) => {
return { return {
show: () => { show: () => {
self.visible = true; self.visible = true;
@ -23,9 +26,9 @@ export const Store = types.model({
}, },
toggle: () => { toggle: () => {
self.visible = !self.visible; self.visible = !self.visible;
} },
}; };
}); });
export type Props = { export type Props = {
store: Instance<typeof Store>; store: Instance<typeof Store>;
@ -40,16 +43,22 @@ export const TopRightMenu = observer((props: Props): JSX.Element => {
menuItems.push(<li key={key}>{item}</li>); menuItems.push(<li key={key}>{item}</li>);
} }
} else if (props.children) { } else if (props.children) {
menuItems.push(<li key={0}>{props.children}</li>) menuItems.push(<li key={0}>{props.children}</li>);
} }
return ( 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}> <div className={Css.menu} style={props.store.style}>
<ul> <ul>{menuItems}</ul>
{menuItems}
</ul>
</div> </div>
</> </>
); );
}) });

View file

@ -3,18 +3,24 @@ import Css from './unreaded-flag.module.css';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Instance, types } from 'mobx-state-tree'; import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from '../redmine-types'; 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>(), issue: types.frozen<RedmineTypes.ExtendedIssue>(),
readingTimestamp: types.number readingTimestamp: types.number,
}).actions((self) => { })
.actions((self) => {
return { return {
read: () => { read: () => {
self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id); self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id);
} },
}; };
}).views((self) => { })
.views((self) => {
return { return {
getUpdatedTimestap(): number { getUpdatedTimestap(): number {
if (self.issue.journals) { if (self.issue.journals) {
@ -27,7 +33,7 @@ export const Store = types.model({
} }
} }
if (lastComment) { if (lastComment) {
return (new Date(lastComment.created_on)).getTime(); return new Date(lastComment.created_on).getTime();
} }
} }
return 0; return 0;
@ -38,27 +44,35 @@ export const Store = types.model({
if (self.readingTimestamp < updatedTimestamp) { if (self.readingTimestamp < updatedTimestamp) {
className += ` ${Css.unreaded}`; 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; return className;
} },
}; };
}); });
export function CreateStoreFromLocalStorage(issue: RedmineTypes.ExtendedIssue) { export function CreateStoreFromLocalStorage(issue: RedmineTypes.ExtendedIssue) {
const timestamp = GetIssueReadingTimestamp(issue.id); const timestamp = GetIssueReadingTimestamp(issue.id);
return Store.create({ return Store.create({
issue: issue, issue: issue,
readingTimestamp: timestamp readingTimestamp: timestamp,
}); });
} }
export type Props = { export type Props = {
store: Instance<typeof Store> store: Instance<typeof Store>;
} };
export const UnreadedFlag = observer((props: Props): JSX.Element => { export const UnreadedFlag = observer((props: Props): JSX.Element => {
const className = props.store.getClassName(); const className = props.store.getClassName();
return ( return (
<span className={className} onClick={(e) => {e.stopPropagation(); props.store.read();}}></span> <span
className={className}
onClick={(e) => {
e.stopPropagation();
props.store.read();
}}
></span>
); );
}) });

View file

@ -1,25 +1,35 @@
import React from "react"; import React from 'react';
import { createBrowserRouter } from "react-router-dom"; import { createBrowserRouter } from 'react-router-dom';
import StartPage from "./start-page/start-page"; import StartPage from './start-page/start-page';
import UnknownPage from "./unknown-page"; import UnknownPage from './unknown-page';
import { KanbanBoardsPage } from "./kanban-board/kanban-boards-page"; import { KanbanBoardsPage } from './kanban-board/kanban-boards-page';
import { IssuesListBoardPage } from "./issues-list-board/issues-list-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([ export const router = createBrowserRouter([
{ {
path: "/", path: '/',
element: (<StartPage/>), element: <StartPage />,
}, },
{ {
path: "/kanban-board/:type/:name", path: '/kanban-board/:type/:name',
element: (<KanbanBoardsPage/>) element: <KanbanBoardsPage />,
}, },
{ {
path: "/issues-list-board/:type/:name", path: '/issues-list-board/:type/:name',
element: (<IssuesListBoardPage/>) element: <IssuesListBoardPage />,
}, },
{ {
path: "*", path: '/dashboards',
element: (<UnknownPage/>) element: <DashboardsPage />,
} },
{
path: '/dashboard/:id',
element: <DashboardPage />,
},
{
path: '*',
element: <UnknownPage />,
},
]); ]);

View file

@ -16,12 +16,23 @@ export const Basement = (props: Props): JSX.Element => {
<div className={BasementCss.basementGrid}> <div className={BasementCss.basementGrid}>
<div className={BasementCss.bottomContacts}> <div className={BasementCss.bottomContacts}>
<a href="/"> <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> <span>redmine-issue-event-emitter</span>
</a> </a>
<p><a href={props.contactUrl}> Проект <p>
<span className={BasementCss.textBoxTextOrange}> Павел Гнедов</span> <a href={props.contactUrl}>
</a></p> {' '}
Проект
<span className={BasementCss.textBoxTextOrange}>
{' '}
Павел Гнедов
</span>
</a>
</p>
</div> </div>
<div className={BasementCss.discuss}> <div className={BasementCss.discuss}>
@ -30,8 +41,12 @@ export const Basement = (props: Props): JSX.Element => {
<p className={BasementCss.discussText}> ОБСУДИТЬ </p> <p className={BasementCss.discussText}> ОБСУДИТЬ </p>
</a> </a>
</div> </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> </div>
</div> </div>

View file

@ -6,11 +6,7 @@ export type Props = {
}; };
export const Content = (props: Props) => { export const Content = (props: Props) => {
return ( return <div className={ContentCss.content}>{props.children}</div>;
<div className={ContentCss.content}>
{props.children}
</div>
);
}; };
export default Content; export default Content;

View file

@ -8,7 +8,11 @@ export type CoverProps = {
export const Cover = (props: CoverProps) => { export const Cover = (props: CoverProps) => {
return ( return (
<div className={CoverCss.cover}> <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}> <div className={CoverCss.info}>
<h3>Redmine Issue Event Emitter</h3> <h3>Redmine Issue Event Emitter</h3>
<h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1> <h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1>

View file

@ -8,12 +8,20 @@ export type Props = {
}; };
export const NotificationBlock = (props: Props) => { export const NotificationBlock = (props: Props) => {
const taskTitle = props?.taskTitle const taskTitle = props?.taskTitle ? (
? (<span className={NotificationBlockCss.text_box_text_blue}>{props.taskTitle} </span>) <span className={NotificationBlockCss.text_box_text_blue}>
: (<></>); {props.taskTitle}{' '}
</span>
) : (
<></>
);
return ( return (
<div className={NotificationBlockCss.message}> <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}> <div className={NotificationBlockCss.text_box}>
<p className={NotificationBlockCss.text_box_text}> <p className={NotificationBlockCss.text_box_text}>
{taskTitle} {taskTitle}

View file

@ -10,7 +10,7 @@ import TopBar from './top-bar';
export const StartPageData = { export const StartPageData = {
contact: 'https://t.me/pavelgnedov', contact: 'https://t.me/pavelgnedov',
bot: 'https://t.me/eltex_event_emitter_bot' bot: 'https://t.me/eltex_event_emitter_bot',
}; };
export const StartPage = () => { export const StartPage = () => {
@ -19,83 +19,120 @@ export const StartPage = () => {
<TopBar contact={StartPageData.contact} /> <TopBar contact={StartPageData.contact} />
<Cover telegramBotUrl={StartPageData.bot} /> <Cover telegramBotUrl={StartPageData.bot} />
<Content> <Content>
<ContentBlock title='Возможности'> <ContentBlock title="Возможности">
<ul> <ul>
<li>Уведомления в реальном времени о событиях из задач - изменения статусов, упоминания комментариев</li> <li>
Уведомления в реальном времени о событиях из задач - изменения
статусов, упоминания комментариев
</li>
<li>Генерация и управления отчётами о задачах</li> <li>Генерация и управления отчётами о задачах</li>
<li>Под капотом приложение фреймворк</li> <li>Под капотом приложение фреймворк</li>
</ul> </ul>
</ContentBlock> </ContentBlock>
<ContentBlock title='Функции telegram бота'> <ContentBlock title="Функции telegram бота">
<ul> <ul>
<li>Последний отчёт для дейли проект ECCM</li> <li>Последний отчёт для дейли проект ECCM</li>
<li>Дополнительные функции для разработчиков <li>
eccm:/current_issues_eccm - список текущих задач по статусам - выбираютсятолько задачи из актуальных версий в статусах, где нужна какая-то реакцияили возможна работа прямо сейчас</li> Дополнительные функции для разработчиков eccm:/current_issues_eccm
<li>Скриншоты уведомления от бота: - список текущих задач по статусам - выбираютсятолько задачи из
Примеры уведомлений о новых задачах и об изменениях статусов:</li> актуальных версий в статусах, где нужна какая-то реакцияили
возможна работа прямо сейчас
</li>
<li>
Скриншоты уведомления от бота: Примеры уведомлений о новых задачах
и об изменениях статусов:
</li>
</ul> </ul>
<NotificationBlock <NotificationBlock
taskTitle='Feature #245005' taskTitle="Feature #245005"
avatarUrl='/images/event_emitter_eltex_loc-49px.png' avatarUrl="/images/event_emitter_eltex_loc-49px.png"
> >
Реализовать поддержку нового протокола: <br/><br/> Реализовать поддержку нового протокола: <br />
<br />
Стив Джобс изменил статус задачи с Feedback на Closed Стив Джобс изменил статус задачи с Feedback на Closed
</NotificationBlock> </NotificationBlock>
<NotificationBlock <NotificationBlock
taskTitle='Feature #241201' taskTitle="Feature #241201"
avatarUrl='/images/event_emitter_eltex_loc-49px.png' avatarUrl="/images/event_emitter_eltex_loc-49px.png"
> >
Добавить поддержку новых моделей: <br/><br/> Добавить поддержку новых моделей: <br />
<br />
Билл Гейтс создал новую задачу и назначил её на вас Билл Гейтс создал новую задачу и назначил её на вас
</NotificationBlock> </NotificationBlock>
<p>Простые уведомления о движении задач - и больше ничего лишнего. <p>
Простые уведомления о движении задач - и больше ничего лишнего.
Пример уведомления по личному упоминанию в задаче: Пример уведомления по личному упоминанию в задаче:
</p> </p>
<NotificationBlock <NotificationBlock
taskTitle='Question #230033' taskTitle="Question #230033"
avatarUrl='/images/event_emitter_eltex_loc-49px.png' avatarUrl="/images/event_emitter_eltex_loc-49px.png"
> >
Сергей Брин:<br/><br/> Сергей Брин:
<br />
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по описанию к этой задаче. <br />
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по
описанию к этой задаче.
</NotificationBlock> </NotificationBlock>
<NotificationBlock <NotificationBlock
taskTitle='Bug #191122' taskTitle="Bug #191122"
avatarUrl='/images/event_emitter_eltex_loc-49px.png' avatarUrl="/images/event_emitter_eltex_loc-49px.png"
> >
Исправление уязвимости<br/><br/> Исправление уязвимости
<br />
Линус Торвальдс завершил разработку по задаче и передал вам на ревью<br/><br/> <br />
Линус Торвальдс завершил разработку по задаче и передал вам на ревью
<br />
<br />
Кажется получилось поправить проблемку. Глянь мой MR. Кажется получилось поправить проблемку. Глянь мой MR.
</NotificationBlock> </NotificationBlock>
<p>Можно задавать коллегам вопросы прямо из комментария задачи, неотрываясь от её содержимого. Уведомление доставится в считанные минуты с ссылкой на задачу и информацией от кого это уведомление.</p> <p>
<p>Пример запроса моих текущих задач с помощью команды Можно задавать коллегам вопросы прямо из комментария задачи,
<span className={NotificationBlockCss.text_box_text_blue}>/current_issues_eccm</span> неотрываясь от её содержимого. Уведомление доставится в считанные
минуты с ссылкой на задачу и информацией от кого это уведомление.
</p>
<p>
Пример запроса моих текущих задач с помощью команды
<span className={NotificationBlockCss.text_box_text_blue}>
/current_issues_eccm
</span>
</p> </p>
<NotificationBlock <NotificationBlock avatarUrl="/images/event_emitter_eltex_loc-49px.png">
avatarUrl='/images/event_emitter_eltex_loc-49px.png' Бьёрн Страуструп:
> <br />
Бьёрн Страуструп:<br/><br/> <br />
Re-opened:
Re-opened:<br/><br/> <br />
<span className={NotificationBlockCss.text_box_text_blue}> - Feature #223301: </span> <br />
Дополнить stdlib новыми функциями (прио - P4, версия - C++23)<br/><br/> <span className={NotificationBlockCss.text_box_text_blue}>
In Progress:<br/><br/> {' '}
<span className={NotificationBlockCss.text_box_text_blue}> - Question #223411:</span> - 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) Выпуск релиза C++23 (прио - P4, версия - C++23)
</NotificationBlock> </NotificationBlock>
</ContentBlock> </ContentBlock>
</Content> </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> </div>
); );
}; };

View file

@ -13,16 +13,35 @@ const TopBar = (props: TopBarProps): ReactElement => {
<div className={TopBarCss.containerTitle}> <div className={TopBarCss.containerTitle}>
<div className={TopBarCss.logo}> <div className={TopBarCss.logo}>
<a href="/" 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> <span>redmine-issue-event-emitter</span>
</a> </a>
</div> </div>
{props.children} {props.children}
<p><a href="/" target="_blank"> #документация</a></p> <p>
<p><a href={props.contact} target="_blank" rel="noreferrer"> #контакты</a></p> <a href="/" target="_blank">
<p><a href="https://gnedov.info/" target="_blank" rel="noreferrer"> #блог</a></p> {' '}
#документация
</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>
</div> </div>
); );

View file

@ -1,9 +1,7 @@
import React from 'react'; import React from 'react';
export const UnknownPage = () => { export const UnknownPage = () => {
return ( return <p>Unknown page</p>;
<p>Unknown page</p>
)
}; };
export default UnknownPage; export default UnknownPage;

View file

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { onGetIssuesQueueSizeClick, onIssuesRefreshClick } from './service-actions'; import {
onGetIssuesQueueSizeClick,
onIssuesRefreshClick,
} from './service-actions';
export const IssuesForceRefreshButton = (): JSX.Element => { export const IssuesForceRefreshButton = (): JSX.Element => {
return ( return <button onClick={onIssuesRefreshClick}>Force issues refresh</button>;
<button onClick={onIssuesRefreshClick}>Force issues refresh</button>
);
}; };
export const GetIssuesQueueSizeButton = (): JSX.Element => { export const GetIssuesQueueSizeButton = (): JSX.Element => {

View file

@ -1,20 +1,30 @@
import axios from "axios"; import axios from 'axios';
import React from 'react'; import React from 'react';
export const onIssuesRefreshClick = (e: React.MouseEvent) => { export const onIssuesRefreshClick = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return; if (e.target !== e.currentTarget) return;
e.stopPropagation(); 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; 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; if (!list) return;
axios.post(`/redmine-event-emitter/append-issues`, list); 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; if (e.target !== e.currentTarget) return;
e.stopPropagation(); 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 console.debug(`resp -`, resp); // DEBUG
if (!resp || typeof resp.data !== 'number') return; if (!resp || typeof resp.data !== 'number') return;
alert(`Issues queue size - ${resp.data}`); alert(`Issues queue size - ${resp.data}`);

View file

@ -3,9 +3,11 @@
* @param a * @param a
* @returns * @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 '-'; 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 '-'; if (!Number.isFinite(res)) return '-';
return `${parseFloat(res.toFixed(1))}`; return `${parseFloat(res.toFixed(1))}`;
}; };

View file

@ -1,19 +1,22 @@
const formatStringToCamelCase = (str: string): string => { const formatStringToCamelCase = (str: string): string => {
const splitted = str.split("-"); const splitted = str.split('-');
if (splitted.length === 1) return splitted[0]; if (splitted.length === 1) return splitted[0];
return ( return (
splitted[0] + splitted[0] +
splitted splitted
.slice(1) .slice(1)
.map(word => word[0].toUpperCase() + word.slice(1)) .map((word) => word[0].toUpperCase() + word.slice(1))
.join("") .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>; const style = {} as Record<string, string>;
str.split(";").forEach(el => { str.split(';').forEach((el) => {
const [property, value] = el.split(":"); const [property, value] = el.split(':');
if (!property) return; if (!property) return;
const formattedProperty = formatStringToCamelCase(property.trim()); const formattedProperty = formatStringToCamelCase(property.trim());

View file

@ -4,13 +4,13 @@ export function GetIssueReadingTimestamp(issueId: number): number {
} }
export function SetIssueReadingTimestamp(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)); window.localStorage.setItem(getKey(issueId), String(now));
return now; return now;
} }
export function SetIssuesReadingTimestamp(issueIds: number[]): number { export function SetIssuesReadingTimestamp(issueIds: number[]): number {
const now = (new Date()).getTime(); const now = new Date().getTime();
for (let i = 0; i < issueIds.length; i++) { for (let i = 0; i < issueIds.length; i++) {
const issueId = issueIds[i]; const issueId = issueIds[i];
window.localStorage.setItem(getKey(issueId), String(now)); window.localStorage.setItem(getKey(issueId), String(now));

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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 }));
}
}

View file

@ -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);
}

View 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);
}

View file

@ -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>>;
}

View file

@ -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 { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import nano from 'nano'; import nano from 'nano';
import { WidgetInterface } from '../widget-interface';
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer'; 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 namespace ListIssuesByFieldsWidgetNs {
export type Params = { export type Params = {
@ -34,10 +40,10 @@ export namespace ListIssuesByFieldsWidgetNs {
type Params = ListIssuesByFieldsWidgetNs.Params; type Params = ListIssuesByFieldsWidgetNs.Params;
@Injectable() @Injectable()
export class ListIssuesByFieldsWidgetService export class ListIssuesByFieldsWidgetDataLoaderService
implements WidgetInterface<Params, any, any> implements WidgetDataLoaderInterface<Params, any, any>
{ {
private logger = new Logger(ListIssuesByFieldsWidgetService.name); private logger = new Logger(ListIssuesByFieldsWidgetDataLoaderService.name);
private issuesLoader: IssuesServiceNs.IssuesLoader; private issuesLoader: IssuesServiceNs.IssuesLoader;
constructor( constructor(
@ -52,7 +58,7 @@ export class ListIssuesByFieldsWidgetService
return true; return true;
} }
async render(widgetParams: Params): Promise<any> { async load(widgetParams: Params): Promise<Result<any, AppError>> {
let store: FlatIssuesStore; let store: FlatIssuesStore;
if (widgetParams.fromRootIssueId) { if (widgetParams.fromRootIssueId) {
store = await this.getListFromRoot(widgetParams.fromRootIssueId); store = await this.getListFromRoot(widgetParams.fromRootIssueId);
@ -61,7 +67,7 @@ export class ListIssuesByFieldsWidgetService
} else { } else {
const errMsg = `Wrong widgetParams value`; const errMsg = `Wrong widgetParams value`;
this.logger.error(errMsg); this.logger.error(errMsg);
throw new Error(errMsg); return fail(createAppError(errMsg));
} }
await store.enhanceIssues([ await store.enhanceIssues([
this.timePassedHighlightEnhancer, this.timePassedHighlightEnhancer,
@ -87,7 +93,7 @@ export class ListIssuesByFieldsWidgetService
return a.metainfo.title.localeCompare(b.metainfo.title); return a.metainfo.title.localeCompare(b.metainfo.title);
}); });
} }
return res; return success(res);
} }
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> { private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {

View file

@ -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 { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import nano from 'nano'; import nano from 'nano';
import { WidgetInterface } from '../widget-interface';
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer'; 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 ListIssuesByUsersLikeJiraWidgetNs {
export namespace Models { export namespace Models {
@ -29,15 +35,17 @@ export namespace ListIssuesByUsersLikeJiraWidgetNs {
type Params = ListIssuesByUsersLikeJiraWidgetNs.Models.Params; type Params = ListIssuesByUsersLikeJiraWidgetNs.Models.Params;
@Injectable() @Injectable()
export class ListIssuesByUsersLikeJiraWidgetService export class ListIssuesByUsersLikeJiraWidgetDataLoaderService
implements WidgetInterface<Params, any, any> implements WidgetDataLoaderInterface<Params, any, any>
{ {
private logger = new Logger(ListIssuesByUsersLikeJiraWidgetService.name); private logger = new Logger(
ListIssuesByUsersLikeJiraWidgetDataLoaderService.name,
);
private issuesLoader: IssuesServiceNs.IssuesLoader; private issuesLoader: IssuesServiceNs.IssuesLoader;
constructor( constructor(
private issuesService: IssuesService,
private timePassedHighlightEnhancer: TimePassedHighlightEnhancer, private timePassedHighlightEnhancer: TimePassedHighlightEnhancer,
private issuesService: IssuesService,
private issueUrlEnhancer: IssueUrlEnhancer, private issueUrlEnhancer: IssueUrlEnhancer,
) { ) {
this.issuesLoader = this.issuesService.createDynamicIssuesLoader(); this.issuesLoader = this.issuesService.createDynamicIssuesLoader();
@ -47,7 +55,7 @@ export class ListIssuesByUsersLikeJiraWidgetService
return true; return true;
} }
async render(widgetParams: Params): Promise<any> { async load(widgetParams: Params): Promise<Result<any, AppError>> {
let store: FlatIssuesStore; let store: FlatIssuesStore;
if (widgetParams.fromRootIssueId) { if (widgetParams.fromRootIssueId) {
store = await this.getListFromRoot(widgetParams.fromRootIssueId); store = await this.getListFromRoot(widgetParams.fromRootIssueId);
@ -56,7 +64,7 @@ export class ListIssuesByUsersLikeJiraWidgetService
} else { } else {
const errMsg = `Wrong widgetParams value`; const errMsg = `Wrong widgetParams value`;
this.logger.error(errMsg); this.logger.error(errMsg);
throw new Error(errMsg); return fail(createAppError(errMsg));
} }
await store.enhanceIssues([ await store.enhanceIssues([
this.timePassedHighlightEnhancer, this.timePassedHighlightEnhancer,
@ -92,7 +100,7 @@ export class ListIssuesByUsersLikeJiraWidgetService
return a.metainfo.title.localeCompare(b.metainfo.title); return a.metainfo.title.localeCompare(b.metainfo.title);
}); });
} }
return res; return success(res);
} }
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> { private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {

View file

@ -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 { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import nano from 'nano'; import nano from 'nano';
import { WidgetInterface } from '../widget-interface';
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer'; 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 ListIssuesByUsersWidgetNs {
export namespace Models { export namespace Models {
@ -41,10 +47,10 @@ type ExtendedIssue = RedmineTypes.Issue & Record<string, any>;
type FindResult = ListIssuesByUsersWidgetNs.Models.FindResult; type FindResult = ListIssuesByUsersWidgetNs.Models.FindResult;
@Injectable() @Injectable()
export class ListIssuesByUsersWidgetService export class ListIssuesByUsersWidgetDataLoaderService
implements WidgetInterface<Params, any, any> implements WidgetDataLoaderInterface<Params, any, any>
{ {
private logger = new Logger(ListIssuesByUsersWidgetService.name); private logger = new Logger(ListIssuesByUsersWidgetDataLoaderService.name);
private issuesLoader: IssuesServiceNs.IssuesLoader; private issuesLoader: IssuesServiceNs.IssuesLoader;
constructor( constructor(
@ -59,7 +65,7 @@ export class ListIssuesByUsersWidgetService
return true; return true;
} }
async render(widgetParams: Params): Promise<any> { async load(widgetParams: Params): Promise<Result<any, AppError>> {
let store: FlatIssuesStore; let store: FlatIssuesStore;
if (widgetParams.fromRootIssueId) { if (widgetParams.fromRootIssueId) {
store = await this.getListFromRoot(widgetParams.fromRootIssueId); store = await this.getListFromRoot(widgetParams.fromRootIssueId);
@ -68,7 +74,7 @@ export class ListIssuesByUsersWidgetService
} else { } else {
const errMsg = `Wrong widgetParams value`; const errMsg = `Wrong widgetParams value`;
this.logger.error(errMsg); this.logger.error(errMsg);
throw new Error(errMsg); return fail(createAppError(errMsg));
} }
await store.enhanceIssues([ await store.enhanceIssues([
this.timePassedHighlightEnhancer, this.timePassedHighlightEnhancer,
@ -95,7 +101,7 @@ export class ListIssuesByUsersWidgetService
return a.metainfo.title.localeCompare(b.metainfo.title); return a.metainfo.title.localeCompare(b.metainfo.title);
}); });
} }
return res; return success(res);
} }
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> { private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {

View file

@ -11,8 +11,9 @@ import {
TreeIssuesStoreNs, TreeIssuesStoreNs,
} from '@app/event-emitter/utils/tree-issues-store'; } from '@app/event-emitter/utils/tree-issues-store';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { WidgetInterface } from '../widget-interface';
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer'; 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 RootIssueSubTreesWidgetNs {
export namespace Models { export namespace Models {
@ -39,8 +40,8 @@ export namespace RootIssueSubTreesWidgetNs {
type Params = RootIssueSubTreesWidgetNs.Models.Params; type Params = RootIssueSubTreesWidgetNs.Models.Params;
@Injectable() @Injectable()
export class RootIssueSubTreesWidgetService export class RootIssueSubTreesWidgetDataLoaderService
implements WidgetInterface<Params, any, any> implements WidgetDataLoaderInterface<Params, any, any>
{ {
private issuesLoader: IssuesServiceNs.IssuesLoader; private issuesLoader: IssuesServiceNs.IssuesLoader;
@ -56,7 +57,7 @@ export class RootIssueSubTreesWidgetService
return true; return true;
} }
async render(widgetParams: Params): Promise<any> { async load(widgetParams: Params): Promise<Result<any, AppError>> {
const treeStore = new TreeIssuesStore(); const treeStore = new TreeIssuesStore();
const rootIssue = await this.issuesService.getIssue( const rootIssue = await this.issuesService.getIssue(
widgetParams.rootIssueId, widgetParams.rootIssueId,
@ -89,11 +90,12 @@ export class RootIssueSubTreesWidgetService
} }
} }
} }
return stories.map((s) => { const res = stories.map((s) => {
return { return {
data: s.store.groupByStatus(widgetParams.statuses), data: s.store.groupByStatus(widgetParams.statuses),
metainfo: s.metainfo, metainfo: s.metainfo,
}; };
}); });
return success(res);
} }
} }

View 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>>;
}

View file

@ -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');
}
}

View file

@ -18,19 +18,24 @@ import { IssuesService } from './issues/issues.service';
import { IssuesController } from './issues/issues.controller'; import { IssuesController } from './issues/issues.controller';
import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer'; import { TimestampEnhancer } from './issue-enhancers/timestamps-enhancer';
import { EnhancerService } from './issue-enhancers/enhancer.service'; 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 { DynamicLoader } from './configs/dynamic-loader';
import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter'; import { RedminePublicUrlConverter } from './converters/redmine-public-url.converter';
import { IssueUrlEnhancer } from './issue-enhancers/issue-url-enhancer'; 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 { 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 { IssuesUpdaterService } from './issues-updater/issues-updater.service';
import { CalendarEnhancer } from './issue-enhancers/calendar-enhancer'; import { CalendarEnhancer } from './issue-enhancers/calendar-enhancer';
import { CalendarService } from './calendar/calendar.service'; import { CalendarService } from './calendar/calendar.service';
import { CalendarController } from './calendar/calendar.controller'; 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({}) @Module({})
export class EventEmitterModule implements OnModuleInit { export class EventEmitterModule implements OnModuleInit {
@ -48,20 +53,20 @@ export class EventEmitterModule implements OnModuleInit {
CouchDb, CouchDb,
Users, Users,
Issues, Issues,
DashboardsDs,
RedmineUserCacheWriterService, RedmineUserCacheWriterService,
UsersService, UsersService,
IssuesService, IssuesService,
TimestampEnhancer, TimestampEnhancer,
EnhancerService, EnhancerService,
ProjectDashboardService, RootIssueSubTreesWidgetDataLoaderService,
RootIssueSubTreesWidgetService,
DynamicLoader, DynamicLoader,
RedminePublicUrlConverter, RedminePublicUrlConverter,
IssueUrlEnhancer, IssueUrlEnhancer,
ListIssuesByUsersWidgetService, ListIssuesByUsersWidgetDataLoaderService,
ListIssuesByUsersLikeJiraWidgetService, ListIssuesByUsersLikeJiraWidgetDataLoaderService,
TimePassedHighlightEnhancer, TimePassedHighlightEnhancer,
ListIssuesByFieldsWidgetService, ListIssuesByFieldsWidgetDataLoaderService,
{ {
provide: 'ISSUES_UPDATER_SERVICE', provide: 'ISSUES_UPDATER_SERVICE',
useFactory: (configService: ConfigService) => { useFactory: (configService: ConfigService) => {
@ -87,12 +92,18 @@ export class EventEmitterModule implements OnModuleInit {
}, },
{ {
provide: 'CALENDAR_SERVICE', provide: 'CALENDAR_SERVICE',
useFactory: (calendarEnhancer: CalendarEnhancer, issuesService: IssuesService): CalendarService => { useFactory: (
calendarEnhancer: CalendarEnhancer,
issuesService: IssuesService,
): CalendarService => {
const calendarEventsKey = calendarEnhancer.calendarEventsKey; const calendarEventsKey = calendarEnhancer.calendarEventsKey;
return new CalendarService(calendarEventsKey, issuesService); return new CalendarService(calendarEventsKey, issuesService);
}, },
inject: ['CALENDAR_ENHANCER', IssuesService] inject: ['CALENDAR_ENHANCER', IssuesService],
}, },
DashboardsService,
DashboardsDataService,
WidgetsCollectionService,
], ],
exports: [ exports: [
EventEmitterService, EventEmitterService,
@ -102,20 +113,20 @@ export class EventEmitterModule implements OnModuleInit {
CouchDb, CouchDb,
Users, Users,
Issues, Issues,
DashboardsDs,
RedmineUserCacheWriterService, RedmineUserCacheWriterService,
UsersService, UsersService,
IssuesService, IssuesService,
TimestampEnhancer, TimestampEnhancer,
EnhancerService, EnhancerService,
ProjectDashboardService, RootIssueSubTreesWidgetDataLoaderService,
RootIssueSubTreesWidgetService,
DynamicLoader, DynamicLoader,
RedminePublicUrlConverter, RedminePublicUrlConverter,
IssueUrlEnhancer, IssueUrlEnhancer,
ListIssuesByUsersWidgetService, ListIssuesByUsersWidgetDataLoaderService,
ListIssuesByUsersLikeJiraWidgetService, ListIssuesByUsersLikeJiraWidgetDataLoaderService,
TimePassedHighlightEnhancer, TimePassedHighlightEnhancer,
ListIssuesByFieldsWidgetService, ListIssuesByFieldsWidgetDataLoaderService,
{ {
provide: 'ISSUES_UPDATER_SERVICE', provide: 'ISSUES_UPDATER_SERVICE',
useExisting: 'ISSUES_UPDATER_SERVICE', useExisting: 'ISSUES_UPDATER_SERVICE',
@ -128,8 +139,18 @@ export class EventEmitterModule implements OnModuleInit {
provide: 'CALENDAR_SERVICE', provide: 'CALENDAR_SERVICE',
useExisting: 'CALENDAR_SERVICE', useExisting: 'CALENDAR_SERVICE',
}, },
DashboardsService,
DashboardsDataService,
WidgetsCollectionService,
],
controllers: [
MainController,
UsersController,
IssuesController,
CalendarController,
DashboardController,
DashboardsController,
], ],
controllers: [MainController, UsersController, IssuesController, CalendarController],
}; };
} }

View 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;
};

View file

@ -16,6 +16,7 @@ export type MainConfigModel = {
dbs: { dbs: {
users: string; users: string;
issues: string; issues: string;
dashboards: string;
}; };
}; };
webhooks: WebhookConfigItemModel[]; webhooks: WebhookConfigItemModel[];

View file

@ -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;
}
}

View file

@ -1,4 +0,0 @@
export interface WidgetInterface<W, D, R> {
isMyConfig(widgetParams: W): boolean;
render(widgetParams: W, dashboardParams: D): Promise<R>;
}

View 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);
}

View file

@ -43,7 +43,7 @@ import { SetDailyEccmUserCommentBotHandlerService } from './telegram-bot/handler
import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service'; import { DailyEccmWithExtraDataService } from './reports/daily-eccm-with-extra-data.service';
import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller'; import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.controller';
import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer'; 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 { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to-tags-enhancer';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path'; 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 { TagsManagerController } from './tags-manager/tags-manager.controller';
import { CreateTagManagerServiceProvider } from './tags-manager/tags-manager.service'; import { CreateTagManagerServiceProvider } from './tags-manager/tags-manager.service';
import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-enhancer'; 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({ @Module({
imports: [ imports: [
@ -98,7 +100,7 @@ import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-en
DailyEccmUserCommentsService, DailyEccmUserCommentsService,
SetDailyEccmUserCommentBotHandlerService, SetDailyEccmUserCommentBotHandlerService,
DailyEccmWithExtraDataService, DailyEccmWithExtraDataService,
IssuesByTagsWidgetService, IssuesByTagsWidgetDataLoaderService,
{ {
provide: 'CATEGORY_MERGE_TO_TAGS_ENHANCER', provide: 'CATEGORY_MERGE_TO_TAGS_ENHANCER',
useFactory: (configService: ConfigService) => { useFactory: (configService: ConfigService) => {
@ -110,6 +112,7 @@ import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-en
inject: [ConfigService], inject: [ConfigService],
}, },
CreateTagManagerServiceProvider('TAG_MANAGER_SERVICE'), CreateTagManagerServiceProvider('TAG_MANAGER_SERVICE'),
DashboardInitService,
], ],
}) })
export class AppModule implements OnModuleInit { export class AppModule implements OnModuleInit {
@ -136,6 +139,8 @@ export class AppModule implements OnModuleInit {
@Inject('CALENDAR_ENHANCER') @Inject('CALENDAR_ENHANCER')
private calendarEnhancer: CalendarEnhancer, private calendarEnhancer: CalendarEnhancer,
private dashboardInitService: DashboardInitService,
) {} ) {}
onModuleInit() { onModuleInit() {
@ -145,6 +150,7 @@ export class AppModule implements OnModuleInit {
UserMetaInfo.getDatasource(); UserMetaInfo.getDatasource();
DailyEccmReportsDatasource.getDatasource(); DailyEccmReportsDatasource.getDatasource();
DailyEccmReportsUserCommentsDatasource.getDatasource(); DailyEccmReportsUserCommentsDatasource.getDatasource();
DashboardsDs.getDatasource();
this.enhancerService.addEnhancer([ this.enhancerService.addEnhancer([
this.timestampEnhancer, this.timestampEnhancer,
@ -213,6 +219,7 @@ export class AppModule implements OnModuleInit {
}); });
this.initDailyEccmUserCommentsPipeline(); this.initDailyEccmUserCommentsPipeline();
this.initDashbordProviders();
} }
private initDailyEccmUserCommentsPipeline(): void { private initDailyEccmUserCommentsPipeline(): void {
@ -226,4 +233,8 @@ export class AppModule implements OnModuleInit {
}, },
); );
} }
private initDashbordProviders(): void {
this.dashboardInitService.init();
}
} }

View 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));
}
}

View file

@ -1,7 +1,7 @@
import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader';
import { Controller, Get, Param, Render } from '@nestjs/common'; import { Controller, Get, Param, Render } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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'; import { parse } from 'jsonc-parser';
@Controller('simple-issues-list') @Controller('simple-issues-list')
@ -9,7 +9,7 @@ export class SimpleIssuesListController {
private path: string; private path: string;
constructor( constructor(
private issuesByTagsWidgetService: IssuesByTagsWidgetService, private issuesByTagsWidgetDataLoaderService: IssuesByTagsWidgetDataLoaderService,
private dynamicLoader: DynamicLoader, private dynamicLoader: DynamicLoader,
private configService: ConfigService, private configService: ConfigService,
) { ) {
@ -23,7 +23,7 @@ export class SimpleIssuesListController {
ext: 'jsonc', ext: 'jsonc',
parser: parse, parser: parse,
}); });
return await this.issuesByTagsWidgetService.render(cfg); return await this.issuesByTagsWidgetDataLoaderService.load(cfg);
} }
@Get('/by-tags/:name') @Get('/by-tags/:name')

View file

@ -1,18 +1,14 @@
import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader'; import { DynamicLoader } from '@app/event-emitter/configs/dynamic-loader';
import { RedmineEventsGateway } from '@app/event-emitter/events/redmine-events.gateway'; import { RedmineEventsGateway } from '@app/event-emitter/events/redmine-events.gateway';
import { IssuesService } from '@app/event-emitter/issues/issues.service'; 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 { Controller, Get, Logger, Param, Render } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { parse } from 'jsonc-parser'; 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') @Controller('simple-kanban-board')
export class SimpleKanbanBoardController { export class SimpleKanbanBoardController {
@ -20,14 +16,14 @@ export class SimpleKanbanBoardController {
private path: string; private path: string;
constructor( constructor(
private rootIssueSubTreesWidgetService: RootIssueSubTreesWidgetService, private rootIssueSubTreesWidgetDataLoaderService: RootIssueSubTreesWidgetDataLoaderService,
private dynamicLoader: DynamicLoader, private dynamicLoader: DynamicLoader,
private configService: ConfigService, private configService: ConfigService,
private listIssuesByUsersWidgetService: ListIssuesByUsersWidgetService, private listIssuesByUsersWidgetDataLoaderService: ListIssuesByUsersWidgetDataLoaderService,
private listIssuesByUsersLikeJiraWidgetService: ListIssuesByUsersLikeJiraWidgetService, private listIssuesByUsersLikeJiraWidgetDataLoaderService: ListIssuesByUsersLikeJiraWidgetDataLoaderService,
private issuesByTagsWidgetService: IssuesByTagsWidgetService, private issuesByTagsWidgetDataLoaderService: IssuesByTagsWidgetDataLoaderService,
private redmineEventsGateway: RedmineEventsGateway, private redmineEventsGateway: RedmineEventsGateway,
private listIssuesByFieldsWidgetService: ListIssuesByFieldsWidgetService, private listIssuesByFieldsWidgetDataLoaderService: ListIssuesByFieldsWidgetDataLoaderService,
private issuesService: IssuesService, private issuesService: IssuesService,
) { ) {
this.path = this.configService.get<string>('simpleKanbanBoard.path'); this.path = this.configService.get<string>('simpleKanbanBoard.path');
@ -40,7 +36,7 @@ export class SimpleKanbanBoardController {
ext: 'jsonc', ext: 'jsonc',
parser: parse, parser: parse,
}); });
return await this.rootIssueSubTreesWidgetService.render(cfg); return await this.rootIssueSubTreesWidgetDataLoaderService.load(cfg);
} }
@Get('/tree/:name') @Get('/tree/:name')
@ -73,7 +69,7 @@ export class SimpleKanbanBoardController {
ext: 'jsonc', ext: 'jsonc',
parser: parse, parser: parse,
}); });
return await this.listIssuesByUsersWidgetService.render(cfg); return await this.listIssuesByUsersWidgetDataLoaderService.load(cfg);
} }
@Get('/by-users/:name') @Get('/by-users/:name')
@ -89,7 +85,9 @@ export class SimpleKanbanBoardController {
ext: 'jsonc', ext: 'jsonc',
parser: parse, parser: parse,
}); });
return await this.listIssuesByUsersLikeJiraWidgetService.render(cfg); return await this.listIssuesByUsersLikeJiraWidgetDataLoaderService.load(
cfg,
);
} }
@Get('/by-users-like-jira/:name') @Get('/by-users-like-jira/:name')
@ -105,7 +103,7 @@ export class SimpleKanbanBoardController {
ext: 'jsonc', ext: 'jsonc',
parser: parse, parser: parse,
}); });
return await this.issuesByTagsWidgetService.render(cfg); return await this.issuesByTagsWidgetDataLoaderService.load(cfg);
} }
@Get('/by-tags/:name') @Get('/by-tags/:name')
@ -121,7 +119,7 @@ export class SimpleKanbanBoardController {
ext: 'jsonc', ext: 'jsonc',
parser: parse, parser: parse,
}); });
return await this.listIssuesByFieldsWidgetService.render(cfg); return await this.listIssuesByFieldsWidgetDataLoaderService.load(cfg);
} }
@Get('/by-fields/:name') @Get('/by-fields/:name')

View file

@ -6,13 +6,19 @@ import {
IssuesService, IssuesService,
IssuesServiceNs, IssuesServiceNs,
} from '@app/event-emitter/issues/issues.service'; } 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 { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store';
import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key'; import { GetValueFromObjectByKey } from '@app/event-emitter/utils/get-value-from-object-by-key';
import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store'; import { TreeIssuesStore } from '@app/event-emitter/utils/tree-issues-store';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import nano from 'nano'; import nano from 'nano';
import * as PriorityStylesEnhancerNs from '@app/event-emitter/issue-enhancers/priority-styles-enhancer'; 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 namespace IssuesByTagsWidgetNs {
export type Params = { export type Params = {
@ -27,10 +33,10 @@ export namespace IssuesByTagsWidgetNs {
type Params = IssuesByTagsWidgetNs.Params; type Params = IssuesByTagsWidgetNs.Params;
@Injectable() @Injectable()
export class IssuesByTagsWidgetService export class IssuesByTagsWidgetDataLoaderService
implements WidgetInterface<Params, any, any> implements WidgetDataLoaderInterface<Params, any, any>
{ {
private logger = new Logger(IssuesByTagsWidgetService.name); private logger = new Logger(IssuesByTagsWidgetDataLoaderService.name);
private issuesLoader: IssuesServiceNs.IssuesLoader; private issuesLoader: IssuesServiceNs.IssuesLoader;
constructor( constructor(
@ -45,7 +51,7 @@ export class IssuesByTagsWidgetService
return true; return true;
} }
async render(widgetParams: Params): Promise<any> { async load(widgetParams: Params): Promise<Result<any, AppError>> {
let store: FlatIssuesStore; let store: FlatIssuesStore;
if (widgetParams.fromRootIssueId) { if (widgetParams.fromRootIssueId) {
store = await this.getListFromRoot(widgetParams.fromRootIssueId); store = await this.getListFromRoot(widgetParams.fromRootIssueId);
@ -54,7 +60,7 @@ export class IssuesByTagsWidgetService
} else { } else {
const errMsg = `Wrong widgetParams value`; const errMsg = `Wrong widgetParams value`;
this.logger.error(errMsg); this.logger.error(errMsg);
throw new Error(errMsg); return fail(createAppError(errMsg));
} }
await store.enhanceIssues([ await store.enhanceIssues([
this.timePassedHighlightEnhancer, this.timePassedHighlightEnhancer,
@ -100,7 +106,7 @@ export class IssuesByTagsWidgetService
return a.metainfo.title.localeCompare(b.metainfo.title); return a.metainfo.title.localeCompare(b.metainfo.title);
}); });
} }
return res; return success(res);
} }
private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> { private async getListFromRoot(issueId: number): Promise<FlatIssuesStore> {