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": "",
"dbs": {
"users": "",
"issues": ""
"issues": "",
"dashboards": ""
}
}
}

View file

@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@ -2977,6 +2978,30 @@
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
},
"node_modules/@monaco-editor/loader": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz",
"integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==",
"dependencies": {
"state-local": "^1.0.6"
},
"peerDependencies": {
"monaco-editor": ">= 0.21.0 < 1"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
"dependencies": {
"@monaco-editor/loader": "^1.4.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@ -11945,6 +11970,12 @@
"mobx": "^6.3.0"
}
},
"node_modules/monaco-editor": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
"integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==",
"peer": true
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -15205,6 +15236,11 @@
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@ -19072,6 +19108,22 @@
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
},
"@monaco-editor/loader": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz",
"integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==",
"requires": {
"state-local": "^1.0.6"
}
},
"@monaco-editor/react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
"integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
"requires": {
"@monaco-editor/loader": "^1.4.0"
}
},
"@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@ -25596,6 +25648,12 @@
"integrity": "sha512-oe82BNgMr408e6DxMDNat8msXQTuyuqzJ97DPupbhchEfjjHyjsmPSwtXHl+nXiW3tybpb/cr5siUClBqKqv+Q==",
"requires": {}
},
"monaco-editor": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
"integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==",
"peer": true
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -27772,6 +27830,11 @@
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="
},
"state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
},
"statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View file

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

24
frontend/src/.eslintrc.js Normal file
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';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
document.getElementById('root') as HTMLElement,
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);
// If you want to start measuring performance in your app, pass a function

View file

@ -1,36 +1,47 @@
import { observer } from 'mobx-react-lite';
import React from 'react';
import { IBoardStore } from './store';
import * as KanbanBoardStoreNs from '../kanban-board/store';
import Css from './issues-list-board.module.css';
import * as IssuesListCardNs from './issues-list-card';
export type Props = {
store: IBoardStore
}
store: KanbanBoardStoreNs.IBoardStore;
};
export const IssuesListBoard = observer((props: Props): JSX.Element => {
const list: JSX.Element[] = props.store.data.map((issue) => {
return (
<IssuesListCardNs.IssuesListCard store={issue} key={issue.id}/>
);
});
let title: JSX.Element;
if (props.store.metainfo.url) {
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
} else {
title = <>{props.store.metainfo.title}</>;
}
return (
<div className={Css.board}>
<div className={Css.boardName}>
<h2 className={Css.boardHeader} id={props.store.metainfo.title}>{title}</h2>
<a href={`#${props.store.metainfo.title}`}>
<img src="/images/anchor BLUE.svg" alt="anchor" className={Css.anchorIcon} />
</a>
</div>
<div className={Css.listContainer}>
{list}
</div>
</div>
);
});
const list: JSX.Element[] = [];
const data = props.store.data;
for (let i = 0; i < data.length; i++) {
const column = data[i];
const issues: any[] = column.issues;
for (let j = 0; j < issues.length; j++) {
const issue = issues[j];
list.push(
<IssuesListCardNs.IssuesListCard store={issue} key={issue.id} />,
);
}
}
let title: JSX.Element;
if (props.store.metainfo.url) {
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
} else {
title = <>{props.store.metainfo.title}</>;
}
return (
<div className={Css.board}>
<div className={Css.boardName}>
<h2 className={Css.boardHeader} id={props.store.metainfo.title}>
{title}
</h2>
<a href={`#${props.store.metainfo.title}`}>
<img
src="/images/anchor BLUE.svg"
alt="anchor"
className={Css.anchorIcon}
/>
</a>
</div>
<div className={Css.listContainer}>{list}</div>
</div>
);
});

View file

@ -1,24 +1,26 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import * as IssuesListStoreNs from './store';
import * as KanbanBoardStoreNs from '../kanban-board/store';
import * as IssuesListBoardsNs from './issues-list-boards';
export const IssuesListBoardPage = (): JSX.Element => {
const params = useParams();
const name = params.name as string;
const type = params.type as string;
// DEBUG: begin
console.debug(`Issues list page: type=${type}; name=${name}`);
useEffect(() => {
console.debug(`Issues list page: type=${type}; name=${name}`);
});
// DEBUG: end
const store = IssuesListStoreNs.PageStore.create({loaded: false, type: type, name: name});
IssuesListStoreNs.PageStoreLoadData(store);
return (
<IssuesListBoardsNs.IssuesListBoards store={store}/>
);
};
const params = useParams();
const name = params.name as string;
const type = params.type as string;
// DEBUG: begin
console.debug(`Issues list page: type=${type}; name=${name}`);
useEffect(() => {
console.debug(`Issues list page: type=${type}; name=${name}`);
});
// DEBUG: end
const store = KanbanBoardStoreNs.PageStore.create({
loaded: false,
type: type,
name: name,
});
KanbanBoardStoreNs.PageStoreLoadData(store);
return <IssuesListBoardsNs.IssuesListBoards store={store} />;
};

View file

@ -1,41 +1,43 @@
import { observer } from 'mobx-react-lite';
import React from 'react';
import * as IssuesListBoardStore from './store';
import * as KanbanBoardStoreNs from '../kanban-board/store';
import * as IssuesListBoardNs from './issues-list-board';
import * as TopRightMenuNs from '../misc-components/top-right-menu';
import { SetIssuesReadingTimestamp } from '../utils/unreaded-provider';
import * as ServiceActionsButtons from '../utils/service-actions-buttons';
export type Props = {
store: IssuesListBoardStore.IPageStore
store: KanbanBoardStoreNs.IPageStore;
};
export const IssuesListBoards = observer((props: Props): JSX.Element => {
const data = props.store.data;
if (!props.store.loaded || !data) {
return <div>Loading...</div>
}
const list: any[] = [];
for (let i = 0; i < data.length; i++) {
const boardData = data[i];
const key = boardData.metainfo.title;
const board = <IssuesListBoardNs.IssuesListBoard store={boardData} key={key}/>
list.push(board);
}
const topRightMenuStore = TopRightMenuNs.Store.create({visible: false});
const onAllReadItemClick = (e: React.MouseEvent) => {
e.stopPropagation();
SetIssuesReadingTimestamp(props.store.issueIds);
IssuesListBoardStore.PageStoreLoadData(props.store);
};
return (
<>
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
<button onClick={onAllReadItemClick}>Прочитать всё</button>
<ServiceActionsButtons.IssuesForceRefreshButton />
<ServiceActionsButtons.GetIssuesQueueSizeButton />
</TopRightMenuNs.TopRightMenu>
{list}
</>
);
});
const data = props.store.data;
if (!props.store.loaded || !data) {
return <div>Loading...</div>;
}
const list: any[] = [];
for (let i = 0; i < data.length; i++) {
const boardData = data[i];
const key = boardData.metainfo.title;
const board = (
<IssuesListBoardNs.IssuesListBoard store={boardData} key={key} />
);
list.push(board);
}
const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false });
const onAllReadItemClick = (e: React.MouseEvent) => {
e.stopPropagation();
SetIssuesReadingTimestamp(props.store.issueIds);
KanbanBoardStoreNs.PageStoreLoadData(props.store);
};
return (
<>
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
<button onClick={onAllReadItemClick}>Прочитать всё</button>
<ServiceActionsButtons.IssuesForceRefreshButton />
<ServiceActionsButtons.GetIssuesQueueSizeButton />
</TopRightMenuNs.TopRightMenu>
{list}
</>
);
});

View file

@ -11,44 +11,63 @@ import { SpentHoursToFixed } from '../utils/spent-hours-to-fixed';
import { getStyleObjectFromString } from '../utils/style';
export type Props = {
store: IIssueStore
store: IIssueStore;
};
export const defaultPriorityStyleKey = 'priorityStyle';
export const IssuesListCard = observer((props: Props): JSX.Element => {
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store);
const detailsStore = IssueDetailsDialogNs.Store.create({
issue: props.store,
visible: false,
unreadedFlagStore: unreadedStore
});
const priorityStyle = getStyleObjectFromString(props.store[defaultPriorityStyleKey]);
const tagsNewLine = (props.store.styledTags && props.store.styledTags.length > 0) ? <br/> : null;
return (
<div className={Css.todoBlock} onClick={(e) => { e.stopPropagation(); detailsStore.show(); }}>
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
<div className={Css.relevanceColor}>
<TimePassedNs.TimePassed params={{ fromIssue: { issue: props.store, keyName: 'timePassedClass' } }} />
</div>
<div className={Css.importantInformation}>
<span className={Css.issueSubject}>
<IssueHrefNs.IssueHref
url={props.store.url?.url || ''}
subject={props.store.subject}
tracker={props.store.tracker?.name || ''}
id={props.store.id}
/>
</span>
<span> </span>
<span className={Css.timeBox}>{SpentHoursToFixed(props.store.total_spent_hours)} / {SpentHoursToFixed(props.store.total_estimated_hours)}</span>
{tagsNewLine}
<TagsNs.Tags params={{ tags: props.store.styledTags }} />
<div className={Css.positionInfo}>
<span className={Css.timeBox}>{props.store.status.name}</span><span> </span>
<span className={Css.priorityBox} style={priorityStyle}>{props.store.priority.name}</span>
</div>
</div>
</div>
);
});
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store);
const detailsStore = IssueDetailsDialogNs.Store.create({
issue: props.store,
visible: false,
unreadedFlagStore: unreadedStore,
});
const priorityStyle = getStyleObjectFromString(
props.store[defaultPriorityStyleKey],
);
const tagsNewLine =
props.store.styledTags && props.store.styledTags.length > 0 ? <br /> : null;
return (
<div
className={Css.todoBlock}
onClick={(e) => {
e.stopPropagation();
detailsStore.show();
}}
>
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
<div className={Css.relevanceColor}>
<TimePassedNs.TimePassed
params={{
fromIssue: { issue: props.store, keyName: 'timePassedClass' },
}}
/>
</div>
<div className={Css.importantInformation}>
<span className={Css.issueSubject}>
<IssueHrefNs.IssueHref
url={props.store.url?.url || ''}
subject={props.store.subject}
tracker={props.store.tracker?.name || ''}
id={props.store.id}
/>
</span>
<span> </span>
<span className={Css.timeBox}>
{SpentHoursToFixed(props.store.total_spent_hours)} /{' '}
{SpentHoursToFixed(props.store.total_estimated_hours)}
</span>
{tagsNewLine}
<TagsNs.Tags params={{ tags: props.store.styledTags }} />
<div className={Css.positionInfo}>
<span className={Css.timeBox}>{props.store.status.name}</span>
<span> </span>
<span className={Css.priorityBox} style={priorityStyle}>
{props.store?.priority?.name}
</span>
</div>
</div>
</div>
);
});

View file

@ -1,86 +1,93 @@
import { Instance, types } from "mobx-state-tree";
import { RedmineTypes } from "../redmine-types";
import axios from "axios";
import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from '../redmine-types';
import axios from 'axios';
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
export interface IIssueStore extends Instance<typeof IssueStore> {}
export type IIssueStore = Instance<typeof IssueStore>;
export const MetaInfoStore = types.model({
title: types.string,
url: types.maybe(types.string),
rootIssue: types.maybe(types.model({
id: 0,
tracker: types.model({
id: 0,
name: ''
}),
subject: ''
}))
title: types.string,
url: types.maybe(types.string),
rootIssue: types.maybe(
types.model({
id: 0,
tracker: types.model({
id: 0,
name: '',
}),
subject: '',
}),
),
});
export const BoardStore = types.model({
data: types.array(IssueStore),
metainfo: MetaInfoStore
data: types.array(IssueStore),
metainfo: MetaInfoStore,
});
export interface IBoardStore extends Instance<typeof BoardStore> {}
export type IBoardStore = Instance<typeof BoardStore>;
export const PageStore = types.model({
loaded: types.boolean,
type: types.string,
name: types.string,
data: types.maybeNull(
types.array(BoardStore)
)
}).actions((self) => {
return {
setData: (data: any) => {
self.data = data;
self.loaded = true;
}
};
}).views((self) => {
return {
get issueIds(): number[] {
if (!self.data) return [];
const data = self.data;
const res = [] as number[];
for (let i = 0; i < data.length; i++) {
const itemData = data[i];
for (let j = 0; j < itemData.data.length; j++) {
const issue = itemData.data[j];
if (res.indexOf(issue.id) < 0) {
res.push(issue.id);
}
}
}
return res;
}
};
});
export const PageStore = types
.model({
loaded: false,
type: '',
name: '',
data: types.maybeNull(types.array(BoardStore)),
})
.actions((self) => {
return {
setData: (data: any) => {
self.data = data;
self.loaded = true;
},
};
})
.views((self) => {
return {
get issueIds(): number[] {
if (!self.data) return [];
const data = self.data;
const res = [] as number[];
for (let i = 0; i < data.length; i++) {
const itemData = data[i];
for (let j = 0; j < itemData.data.length; j++) {
const issue = itemData.data[j];
if (res.indexOf(issue.id) < 0) {
res.push(issue.id);
}
}
}
return res;
},
};
});
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url);
if (!(resp?.data)) return;
const data = [];
for (let i = 0; i < resp.data.length; i++) {
const item = resp.data[i] as {data: any[], metainfo: Record<string, any>};
data.push({
metainfo: item.metainfo,
data: item.data ? item.data.map((group: { status: string, count: number, issues: any[] }) => {
return group.issues
}).flat() : []
});
}
/* DEBUG: begin */
console.debug(`Issues list board store data: ${JSON.stringify(data)}`);
/* DEBUG: end */
store.setData(data);
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url);
if (!resp?.data) return;
const data = [];
for (let i = 0; i < resp.data.length; i++) {
const item = resp.data[i] as { data: any[]; metainfo: Record<string, any> };
data.push({
metainfo: item.metainfo,
data: item.data
? item.data
.map((group: { status: string; count: number; issues: any[] }) => {
return group.issues;
})
.flat()
: [],
});
}
/* DEBUG: begin */
console.debug(`Issues list board store data: ${JSON.stringify(data)}`);
/* DEBUG: end */
store.setData(data);
}
export interface IPageStore extends Instance<typeof PageStore> {}
export type IPageStore = Instance<typeof PageStore>;

View file

@ -5,23 +5,26 @@ import { observer } from 'mobx-react-lite';
import * as KanbanCard from './kanban-card';
export type Props = {
store: Stores.IColumnStore
}
store: Stores.IColumnStore;
};
export const Column = observer((props: Props) => {
const cards = props.store.cards.map((card) => {
return (
<KanbanCard.KanbanCard store={card} key={card.issue.id}></KanbanCard.KanbanCard>
);
});
return (
<div className={ColumnCss.kanbanColumn}>
<div className={ColumnCss.kanbanHeader}>
{props.store.status} ({props.store.count})
</div>
{cards}
</div>
);
const cards = props.store.cards.map((card) => {
return (
<KanbanCard.KanbanCard
store={card}
key={card.issue.id}
></KanbanCard.KanbanCard>
);
});
return (
<div className={ColumnCss.kanbanColumn}>
<div className={ColumnCss.kanbanHeader}>
{props.store.status} ({props.store.count})
</div>
{cards}
</div>
);
});
export default Column;

View file

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

View file

@ -4,19 +4,24 @@ import * as Stores from './store';
import * as KBS from './kanban-boards';
export const KanbanBoardsPage = (): JSX.Element => {
const params = useParams();
const name = params.name as string;
const type = params.type as string;
// DEBUG: begin
console.debug(`KanbanBoardsPage: type=${type}; name=${name}`);
useEffect(() => {
console.debug(`KanbanBoardsPage: type=${type}; name=${name}`);
});
// DEBUG: end
const store = Stores.PageStore.create({loaded: false, type: type, name: name, data: null});
Stores.PageStoreLoadData(store);
return <KBS.KanbanBoards store={store}/>;
}
const params = useParams();
const name = params.name as string;
const type = params.type as string;
// DEBUG: begin
console.debug(`KanbanBoardsPage: type=${type}; name=${name}`);
useEffect(() => {
console.debug(`KanbanBoardsPage: type=${type}; name=${name}`);
});
// DEBUG: end
const store = Stores.PageStore.create({
loaded: false,
type: type,
name: name,
data: null,
});
Stores.PageStoreLoadData(store);
return <KBS.KanbanBoards store={store} />;
};

View file

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

View file

@ -9,70 +9,84 @@ import * as IssueDetailsDialogNs from '../misc-components/issue-details-dialog';
import * as UnreadedFlagNs from '../misc-components/unreaded-flag';
export type Props = {
store: ICardStore
store: ICardStore;
};
export type TagProps = {
style?: string;
tag: string;
style?: string;
tag: string;
};
export const KanbanCardTag = (props: TagProps): JSX.Element => {
const inlineStyle = getStyleObjectFromString(props.style || '');
return (
<span className={KanbanCardCss.kanbanCardTag} style={inlineStyle}>
{props.tag}
</span>
);
}
const inlineStyle = getStyleObjectFromString(props.style || '');
return (
<span className={KanbanCardCss.kanbanCardTag} style={inlineStyle}>
{props.tag}
</span>
);
};
/**
* Какие дальше требования к карточкам?
*
*
* 1. Отобразить как было в статичной доске
* 2. Переделать отображение с учётом store.params
*/
export const KanbanCard = observer((props: Props) => {
let tagsSection = <></>;
const tagsParams = props.store.params.fields.find((field) => {
return field.component === 'tags';
});
console.debug('Tag params:', tagsParams); // DEBUG
console.debug('Issue:', props.store.issue); // DEBUG
if (tagsParams && props.store.issue[tagsParams.path]) {
const tags = props.store.issue[tagsParams.path] as TagProps[];
console.debug(`Tags:`, tags); // DEBUG
tagsSection = <TagsNs.Tags params={{tags: tags}}/>
}
const timePassedParams: TimePassedNs.Params = {
fromIssue: {
issue: props.store.issue,
keyName: 'timePassedClass'
}
}
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store.issue);
const detailsStore = IssueDetailsDialogNs.Store.create({
issue: props.store.issue,
visible: false,
unreadedFlagStore: unreadedStore
});
return (
<div className={KanbanCardCss.kanbanCard} onClick={(e) => {e.stopPropagation(); detailsStore.show();}}>
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
<div className={KanbanCardCss.kanbanCardTitle}>
<UnreadedFlagNs.UnreadedFlag store={unreadedStore}/>
<TimePassedNs.TimePassed params={timePassedParams}/>
<a href={props.store.issue.url.url}>{props.store.issue.tracker.name} #{props.store.issue.id} - {props.store.issue.subject}</a>
</div>
<div>Исп.: {props.store.issue.current_user.name}</div>
<div>Прио.: {props.store.issue.priority.name}</div>
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div>
<div>Прогресс: {props.store.issue.done_ratio}</div>
<div>Трудозатраты: {props.store.issue.total_spent_hours} / {props.store.issue.total_estimated_hours}</div>
{tagsSection}
</div>
);
let tagsSection = <></>;
const tagsParams = props.store.params.fields.find((field) => {
return field.component === 'tags';
});
console.debug('Tag params:', tagsParams); // DEBUG
console.debug('Issue:', props.store.issue); // DEBUG
if (tagsParams && props.store.issue[tagsParams.path]) {
const tags = props.store.issue[tagsParams.path] as TagProps[];
console.debug(`Tags:`, tags); // DEBUG
tagsSection = <TagsNs.Tags params={{ tags: tags }} />;
}
const timePassedParams: TimePassedNs.Params = {
fromIssue: {
issue: props.store.issue,
keyName: 'timePassedClass',
},
};
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(
props.store.issue,
);
const detailsStore = IssueDetailsDialogNs.Store.create({
issue: props.store.issue,
visible: false,
unreadedFlagStore: unreadedStore,
});
return (
<div
className={KanbanCardCss.kanbanCard}
onClick={(e) => {
e.stopPropagation();
detailsStore.show();
}}
>
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
<div className={KanbanCardCss.kanbanCardTitle}>
<UnreadedFlagNs.UnreadedFlag store={unreadedStore} />
<TimePassedNs.TimePassed params={timePassedParams} />
<a href={props.store.issue.url.url}>
{props.store.issue.tracker.name} #{props.store.issue.id} -{' '}
{props.store.issue.subject}
</a>
</div>
<div>Исп.: {props.store.issue.current_user.name}</div>
<div>Прио.: {props.store.issue.priority.name}</div>
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div>
<div>Прогресс: {props.store.issue.done_ratio}</div>
<div>
Трудозатраты: {props.store.issue.total_spent_hours} /{' '}
{props.store.issue.total_estimated_hours}
</div>
{tagsSection}
</div>
);
});
export default KanbanCard;
export default KanbanCard;

View file

@ -2,126 +2,129 @@ import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from '../redmine-types';
import axios from 'axios';
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>()
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
export interface IIssueStore extends Instance<typeof IssueStore> {}
export type IIssueStore = Instance<typeof IssueStore>;
export const ColumnStore = types.model({
status: '',
count: 0,
issues: types.array(IssueStore)
}).views((self) => {
return {
get cards(): ICardStore[] {
return self.issues.map(issue => {
return CardStore.create({
issue: issue
})
});
}
}
});
export const ColumnStore = types
.model({
status: '',
count: 0,
issues: types.array(IssueStore),
})
.views((self) => {
return {
get cards(): ICardStore[] {
return self.issues.map((issue) => {
return CardStore.create({
issue: issue,
});
});
},
};
});
export interface IColumnStore extends Instance<typeof ColumnStore> {}
export type IColumnStore = Instance<typeof ColumnStore>;
export const MetaInfoStore = types.model({
title: '',
url: types.maybe(types.string),
rootIssue: types.maybe(types.model({
id: 0,
tracker: types.model({
id: 0,
name: ''
}),
subject: ''
}))
title: '',
url: types.maybe(types.string),
rootIssue: types.maybe(
types.model({
id: 0,
tracker: types.model({
id: 0,
name: '',
}),
subject: '',
}),
),
});
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {}
export type IMetaInfoStore = Instance<typeof MetaInfoStore>;
export const BoardStore = types.model({
data: types.array(ColumnStore),
metainfo: MetaInfoStore
data: types.array(ColumnStore),
metainfo: MetaInfoStore,
});
export interface IBoardStore extends Instance<typeof BoardStore> {}
export type IBoardStore = Instance<typeof BoardStore>;
export const PageStore = types.model({
loaded: false,
type: '',
name: '',
data: types.maybeNull(
types.array(BoardStore)
)
}).actions(self => {
return {
setData: (data: any) => {
self.data = data;
self.loaded = true;
}
};
}).views((self) => {
return {
get issueIds(): number[] {
if (!self.data) return [];
const res = [] as number[];
for (let i = 0; i < self.data.length; i++) {
const iData = self.data[i];
for (let j = 0; j < iData.data.length; j++) {
const jData = iData.data[j];
for (let k = 0; k < jData.issues.length; k++) {
const issue = jData.issues[k];
if (res.indexOf(issue.id) < 0) {
res.push(issue.id);
}
}
}
}
return res;
},
get canTreeRefresh(): boolean {
return (self.type === 'tree');
}
};
});
export const PageStore = types
.model({
loaded: false,
type: '',
name: '',
data: types.maybeNull(types.array(BoardStore)),
})
.actions((self) => {
return {
setData: (data: any) => {
console.debug('Kanban page store new data -', data); // DEBUG
self.data = data;
self.loaded = true;
},
};
})
.views((self) => {
return {
get issueIds(): number[] {
if (!self.data) return [];
const res = [] as number[];
for (let i = 0; i < self.data.length; i++) {
const iData = self.data[i];
for (let j = 0; j < iData.data.length; j++) {
const jData = iData.data[j];
for (let k = 0; k < jData.issues.length; k++) {
const issue = jData.issues[k];
if (res.indexOf(issue.id) < 0) {
res.push(issue.id);
}
}
}
}
return res;
},
get canTreeRefresh(): boolean {
return self.type === 'tree';
},
};
});
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url);
if (!(resp?.data)) return;
store.setData(resp.data);
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url);
if (!resp?.data) return;
store.setData(resp.data);
}
export interface IPageStore extends Instance<typeof PageStore> { }
export type IPageStore = Instance<typeof PageStore>;
export type CardField = {
component: string;
component: string;
} & Record<string, any>;
export const CardParamsStore = types.optional(
types.model({
fields: types.array(
types.frozen<CardField>()
),
autoCollapse: types.boolean
}),
{
fields: [
{ component: 'text', label: 'Исп.', path: 'current_user.name' },
{ component: 'text', label: 'Прио.', path: 'priority.name' },
{ component: 'text', label: 'Версия', path: 'fixed_version.name' },
{ component: 'text', label: 'Прогресс', path: 'done_ratio' },
{ component: 'labor_costs' },
{ component: 'tags', label: 'Tags', path: 'styledTags' }
],
autoCollapse: false,
}
types.model({
fields: types.array(types.frozen<CardField>()),
autoCollapse: types.boolean,
}),
{
fields: [
{ component: 'text', label: 'Исп.', path: 'current_user.name' },
{ component: 'text', label: 'Прио.', path: 'priority.name' },
{ component: 'text', label: 'Версия', path: 'fixed_version.name' },
{ component: 'text', label: 'Прогресс', path: 'done_ratio' },
{ component: 'labor_costs' },
{ component: 'tags', label: 'Tags', path: 'styledTags' },
],
autoCollapse: false,
},
);
export const CardStore = types.model({
issue: IssueStore,
params: CardParamsStore
issue: IssueStore,
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,107 +9,118 @@ import { SetIssueReadingTimestamp } from '../utils/unreaded-provider';
import axios from 'axios';
import * as Luxon from 'luxon';
export const Store = types.model({
visible: types.boolean,
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store)
}).actions((self) => {
return {
hide: () => {
console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG
self.visible = false;
},
show: () => {
console.debug(`Issue details dialog show: issue_id=${self.issue.id}`); // DEBUG
self.visible = true;
if (self.unreadedFlagStore) {
self.unreadedFlagStore.read();
} else {
SetIssueReadingTimestamp(self.issue.id);
}
}
};
}).views((self) => {
return {
get displayStyle(): React.CSSProperties {
return {display: self.visible ? 'block' : 'none'};
}
};
});
export const Store = types
.model({
visible: types.boolean,
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store),
})
.actions((self) => {
return {
hide: () => {
console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG
self.visible = false;
},
show: () => {
console.debug(`Issue details dialog show: issue_id=${self.issue.id}`); // DEBUG
self.visible = true;
if (self.unreadedFlagStore) {
self.unreadedFlagStore.read();
} else {
SetIssueReadingTimestamp(self.issue.id);
}
},
};
})
.views((self) => {
return {
get displayStyle(): React.CSSProperties {
return { display: self.visible ? 'block' : 'none' };
},
};
});
export type Props = {
store: Instance<typeof Store>
store: Instance<typeof Store>;
};
export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
const onUpdateClick = (e: React.MouseEvent) => {
const url = `${process.env.REACT_APP_BACKEND}redmine-event-emitter/append-issues`;
axios.post(url, [props.store.issue.id]);
};
const onCloseClick = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return;
e.stopPropagation();
props.store.hide();
};
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={onUpdateClick}>force update</button>
<IssueHrefNs.IssueHref
id={props.store.issue?.id || -1}
subject={props.store.issue?.subject || ''}
tracker={props.store.issue?.tracker?.name || ''}
url={props.store.issue?.url?.url || ''}
/>
</h1>
<hr/>
<div>
<h2>Описание:</h2>
<pre>
{props.store.issue.description}
</pre>
</div>
<hr/>
<div>
<h2>Комментарии:</h2>
<Comments details={props.store.issue.journals || []} issue={props.store.issue}/>
</div>
</div>
</div>
</div>
);
const onUpdateClick = (e: React.MouseEvent) => {
const url = `${process.env.REACT_APP_BACKEND}redmine-event-emitter/append-issues`;
axios.post(url, [props.store.issue.id]);
};
const onCloseClick = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return;
e.stopPropagation();
props.store.hide();
};
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={onUpdateClick}>force update</button>
<IssueHrefNs.IssueHref
id={props.store.issue?.id || -1}
subject={props.store.issue?.subject || ''}
tracker={props.store.issue?.tracker?.name || ''}
url={props.store.issue?.url?.url || ''}
/>
</h1>
<hr />
<div>
<h2>Описание:</h2>
<pre>{props.store.issue.description}</pre>
</div>
<hr />
<div>
<h2>Комментарии:</h2>
<Comments
details={props.store.issue.journals || []}
issue={props.store.issue}
/>
</div>
</div>
</div>
</div>
);
});
export const Comments = (props: {details?: RedmineTypes.Journal[], issue: RedmineTypes.ExtendedIssue}): JSX.Element => {
const comments = props.details?.filter((detail) => {
return Boolean(detail.notes);
});
if (!comments) {
return <>No comments</>
}
const list = comments.map((detail) => {
const key = `issueid_${props.issue.id}_commentid_${detail.id}`;
return <Comment data={detail} key={key}/>
});
return (
<>{list}</>
);
}
export const Comments = (props: {
details?: RedmineTypes.Journal[];
issue: RedmineTypes.ExtendedIssue;
}): JSX.Element => {
const comments = props.details?.filter((detail) => {
return Boolean(detail.notes);
});
if (!comments) {
return <>No comments</>;
}
const list = comments.map((detail) => {
const key = `issueid_${props.issue.id}_commentid_${detail.id}`;
return <Comment data={detail} key={key} />;
});
return <>{list}</>;
};
export const Comment = (props: {data: RedmineTypes.Journal}): JSX.Element => {
const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat("dd.MM.yyyy HH:mm");
return (
<>
<h3><span className={Css.dateField}>{date}</span> {props.data.user.name}:</h3>
<div>
<pre>
{props.data.notes || '-'}
</pre>
</div>
<hr/>
</>
);
}
export const Comment = (props: { data: RedmineTypes.Journal }): JSX.Element => {
const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat(
'dd.MM.yyyy HH:mm',
);
return (
<>
<h3>
<span className={Css.dateField}>{date}</span> {props.data.user.name}:
</h3>
<div>
<pre>{props.data.notes || '-'}</pre>
</div>
<hr />
</>
);
};

View file

@ -1,14 +1,16 @@
import React from 'react';
export type Props = {
url: string;
id: number;
subject: string;
tracker: string;
url: string;
id: number;
subject: string;
tracker: string;
};
export const IssueHref = (props: Props): JSX.Element => {
return (
<a href={props.url}>{props.tracker} #{props.id} - {props.subject}</a>
);
};
return (
<a href={props.url}>
{props.tracker} #{props.id} - {props.subject}
</a>
);
};

View file

@ -3,18 +3,18 @@ import { getStyleObjectFromString } from '../utils/style';
import Css from './tag.module.css';
export type Props = {
style?: string;
tag: string;
style?: string;
tag: string;
};
export const Tag = (props: Props): JSX.Element => {
const inlineStyle = getStyleObjectFromString(props.style || '');
return (
<>
<span> </span>
<span className={Css.tag} style={inlineStyle}>
{props.tag}
</span>
</>
);
}
const inlineStyle = getStyleObjectFromString(props.style || '');
return (
<>
<span> </span>
<span className={Css.tag} style={inlineStyle}>
{props.tag}
</span>
</>
);
};

View file

@ -2,26 +2,28 @@ import React from 'react';
import * as TagNs from './tag';
export type Params = {
label?: string;
tags: TagNs.Props[];
label?: string;
tags: TagNs.Props[];
};
export type Props = {
params: Params
params: Params;
};
export const Tags = (props: Props): JSX.Element => {
if (!props.params.tags) {
return (<></>);
}
let label = props.params.label || '';
if (label) label = `${label}: `;
const tags = props.params.tags.map((tag) => {
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag}/>;
}) || [];
return (
<>
{label}{tags}
</>
);
}
if (!props.params.tags) {
return <></>;
}
let label = props.params.label || '';
if (label) label = `${label}: `;
const tags =
props.params.tags.map((tag) => {
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag} />;
}) || [];
return (
<>
{label}
{tags}
</>
);
};

View file

@ -3,46 +3,48 @@ import Css from './time-passed.module.css';
import { RedmineTypes } from '../redmine-types';
export type Params = {
fromIssue?: {
issue: RedmineTypes.ExtendedIssue,
keyName: string,
},
fromValue?: string
fromIssue?: {
issue: RedmineTypes.ExtendedIssue;
keyName: string;
};
fromValue?: string;
};
export type Props = {
params: Params
params: Params;
};
export const TimePassed = (props: Props): JSX.Element => {
if (!props.params.fromIssue && !props.params.fromValue) {
return <></>;
}
let timePassedClassName = ''; // TODO
if (props.params.fromIssue) {
const { issue, keyName } = props.params.fromIssue;
timePassedClassName = `${Css.timepassedDot} ${getClassName(issue[keyName])}`;
} else if (props.params.fromValue) {
timePassedClassName = `${Css.timepassedDot} ${getClassName(props.params.fromValue)}`;
}
return (
<span className={timePassedClassName}></span>
);
if (!props.params.fromIssue && !props.params.fromValue) {
return <></>;
}
let timePassedClassName = ''; // TODO
if (props.params.fromIssue) {
const { issue, keyName } = props.params.fromIssue;
timePassedClassName = `${Css.timepassedDot} ${getClassName(
issue[keyName],
)}`;
} else if (props.params.fromValue) {
timePassedClassName = `${Css.timepassedDot} ${getClassName(
props.params.fromValue,
)}`;
}
return <span className={timePassedClassName}></span>;
};
function getClassName(value: string): string {
switch (value) {
case 'hot':
return Css.hot;
case 'warm':
return Css.warm;
case 'comfort':
return Css.comfort;
case 'breezy':
return Css.breezy;
case 'cold':
return Css.cold;
default:
return '';
}
}
switch (value) {
case 'hot':
return Css.hot;
case 'warm':
return Css.warm;
case 'comfort':
return Css.comfort;
case 'breezy':
return Css.breezy;
case 'cold':
return Css.cold;
default:
return '';
}
}

View file

@ -3,53 +3,62 @@ import { Instance, types } from 'mobx-state-tree';
import React from 'react';
import Css from './top-right-menu.module.css';
export const Store = types.model({
visible: types.boolean
}).views((self) => {
return {
get style(): React.CSSProperties {
return {
display: self.visible ? 'block' : 'none'
};
}
};
}).actions((self) => {
return {
show: () => {
self.visible = true;
},
hide: () => {
self.visible = false;
},
toggle: () => {
self.visible = !self.visible;
}
};
});
export const Store = types
.model({
visible: types.boolean,
})
.views((self) => {
return {
get style(): React.CSSProperties {
return {
display: self.visible ? 'block' : 'none',
};
},
};
})
.actions((self) => {
return {
show: () => {
self.visible = true;
},
hide: () => {
self.visible = false;
},
toggle: () => {
self.visible = !self.visible;
},
};
});
export type Props = {
store: Instance<typeof Store>;
children?: any;
store: Instance<typeof Store>;
children?: any;
};
export const TopRightMenu = observer((props: Props): JSX.Element => {
const menuItems = [];
if (props.children.length > 1) {
for (let key = 0; key < props.children.length; key++) {
const item = props.children[key];
menuItems.push(<li key={key}>{item}</li>);
}
} else if (props.children) {
menuItems.push(<li key={0}>{props.children}</li>)
}
return (
<>
<button className={Css.menuButton} onClick={(e) => {e.stopPropagation(); props.store.toggle();}}>Menu</button>
<div className={Css.menu} style={props.store.style}>
<ul>
{menuItems}
</ul>
</div>
</>
);
})
const menuItems = [];
if (props.children.length > 1) {
for (let key = 0; key < props.children.length; key++) {
const item = props.children[key];
menuItems.push(<li key={key}>{item}</li>);
}
} else if (props.children) {
menuItems.push(<li key={0}>{props.children}</li>);
}
return (
<>
<button
className={Css.menuButton}
onClick={(e) => {
e.stopPropagation();
props.store.toggle();
}}
>
Menu
</button>
<div className={Css.menu} style={props.store.style}>
<ul>{menuItems}</ul>
</div>
</>
);
});

View file

@ -3,62 +3,76 @@ import Css from './unreaded-flag.module.css';
import { observer } from 'mobx-react-lite';
import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from '../redmine-types';
import { GetIssueReadingTimestamp, SetIssueReadingTimestamp } from '../utils/unreaded-provider';
import {
GetIssueReadingTimestamp,
SetIssueReadingTimestamp,
} from '../utils/unreaded-provider';
export const Store = types.model({
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
readingTimestamp: types.number
}).actions((self) => {
return {
read: () => {
self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id);
}
};
}).views((self) => {
return {
getUpdatedTimestap(): number {
if (self.issue.journals) {
let lastComment: RedmineTypes.Journal | undefined;
for (let i = self.issue.journals.length - 1; i >= 0; i--) {
const journal = self.issue.journals[i];
if (journal.notes) {
lastComment = journal;
break;
}
}
if (lastComment) {
return (new Date(lastComment.created_on)).getTime();
}
}
return 0;
},
getClassName(): string {
let className = Css.circle;
const updatedTimestamp = this.getUpdatedTimestap();
if (self.readingTimestamp < updatedTimestamp) {
className += ` ${Css.unreaded}`;
}
console.debug(`Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`); // DEBUG
return className;
}
};
});
export const Store = types
.model({
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
readingTimestamp: types.number,
})
.actions((self) => {
return {
read: () => {
self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id);
},
};
})
.views((self) => {
return {
getUpdatedTimestap(): number {
if (self.issue.journals) {
let lastComment: RedmineTypes.Journal | undefined;
for (let i = self.issue.journals.length - 1; i >= 0; i--) {
const journal = self.issue.journals[i];
if (journal.notes) {
lastComment = journal;
break;
}
}
if (lastComment) {
return new Date(lastComment.created_on).getTime();
}
}
return 0;
},
getClassName(): string {
let className = Css.circle;
const updatedTimestamp = this.getUpdatedTimestap();
if (self.readingTimestamp < updatedTimestamp) {
className += ` ${Css.unreaded}`;
}
console.debug(
`Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`,
); // DEBUG
return className;
},
};
});
export function CreateStoreFromLocalStorage(issue: RedmineTypes.ExtendedIssue) {
const timestamp = GetIssueReadingTimestamp(issue.id);
return Store.create({
issue: issue,
readingTimestamp: timestamp
});
const timestamp = GetIssueReadingTimestamp(issue.id);
return Store.create({
issue: issue,
readingTimestamp: timestamp,
});
}
export type Props = {
store: Instance<typeof Store>
}
store: Instance<typeof Store>;
};
export const UnreadedFlag = observer((props: Props): JSX.Element => {
const className = props.store.getClassName();
return (
<span className={className} onClick={(e) => {e.stopPropagation(); props.store.read();}}></span>
);
})
const className = props.store.getClassName();
return (
<span
className={className}
onClick={(e) => {
e.stopPropagation();
props.store.read();
}}
></span>
);
});

View file

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

View file

@ -10,18 +10,29 @@ export type Props = {
export const Basement = (props: Props): JSX.Element => {
console.debug('routes:', router.routes); // DEBUG
return (
<div className={BasementCss.basement}>
<div className={BasementCss.basementGrid}>
<div className={BasementCss.bottomContacts}>
<a href="/">
<img src={props.iconUrl} alt="event_emitter_eltex_loc" className={BasementCss.eventEmitterEltexLoc} />
<img
src={props.iconUrl}
alt="event_emitter_eltex_loc"
className={BasementCss.eventEmitterEltexLoc}
/>
<span>redmine-issue-event-emitter</span>
</a>
<p><a href={props.contactUrl}> Проект
<span className={BasementCss.textBoxTextOrange}> Павел Гнедов</span>
</a></p>
<p>
<a href={props.contactUrl}>
{' '}
Проект
<span className={BasementCss.textBoxTextOrange}>
{' '}
Павел Гнедов
</span>
</a>
</p>
</div>
<div className={BasementCss.discuss}>
@ -30,8 +41,12 @@ export const Basement = (props: Props): JSX.Element => {
<p className={BasementCss.discussText}> ОБСУДИТЬ </p>
</a>
</div>
<img src={props.characterUrl} width="100" alt="Сharacter" className={BasementCss.character02} />
<img
src={props.characterUrl}
width="100"
alt="Сharacter"
className={BasementCss.character02}
/>
</div>
</div>
</div>

View file

@ -1,17 +1,17 @@
import React from 'react';
export type Props = {
title: string;
children?: any;
title: string;
children?: any;
};
export const ContentBlock = (props: Props) => {
return (
<>
<h2>{props.title}</h2>
{props.children}
</>
);
return (
<>
<h2>{props.title}</h2>
{props.children}
</>
);
};
export default ContentBlock;
export default ContentBlock;

View file

@ -2,15 +2,11 @@ import React from 'react';
import ContentCss from './content.module.css';
export type Props = {
children?: any;
children?: any;
};
export const Content = (props: Props) => {
return (
<div className={ContentCss.content}>
{props.children}
</div>
);
return <div className={ContentCss.content}>{props.children}</div>;
};
export default Content;
export default Content;

View file

@ -2,22 +2,26 @@ import React from 'react';
import CoverCss from './cover.module.css';
export type CoverProps = {
telegramBotUrl: string;
telegramBotUrl: string;
};
export const Cover = (props: CoverProps) => {
return (
<div className={CoverCss.cover}>
<img src="/images/Сharacter_01.png" alt="Сharacter" className={CoverCss.character} />
<div className={CoverCss.info}>
<h3>Redmine Issue Event Emitter</h3>
<h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1>
<h4>
<a href={props.telegramBotUrl}> ссылка на телеграмм бота</a>
</h4>
</div>
</div>
);
return (
<div className={CoverCss.cover}>
<img
src="/images/Сharacter_01.png"
alt="Сharacter"
className={CoverCss.character}
/>
<div className={CoverCss.info}>
<h3>Redmine Issue Event Emitter</h3>
<h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1>
<h4>
<a href={props.telegramBotUrl}> ссылка на телеграмм бота</a>
</h4>
</div>
</div>
);
};
export default Cover;
export default Cover;

View file

@ -2,26 +2,34 @@ import React from 'react';
import NotificationBlockCss from './notification-block.module.css';
export type Props = {
avatarUrl: string;
taskTitle?: string;
children?: any;
avatarUrl: string;
taskTitle?: string;
children?: any;
};
export const NotificationBlock = (props: Props) => {
const taskTitle = props?.taskTitle
? (<span className={NotificationBlockCss.text_box_text_blue}>{props.taskTitle} </span>)
: (<></>);
return (
<div className={NotificationBlockCss.message}>
<img src={props.avatarUrl} alt="event_emitter_eltex_loc" className={NotificationBlockCss.event_emitter_eltex_loc_icon} />
<div className={NotificationBlockCss.text_box}>
<p className={NotificationBlockCss.text_box_text}>
{taskTitle}
{props.children}
</p>
</div>
</div>
);
const taskTitle = props?.taskTitle ? (
<span className={NotificationBlockCss.text_box_text_blue}>
{props.taskTitle}{' '}
</span>
) : (
<></>
);
return (
<div className={NotificationBlockCss.message}>
<img
src={props.avatarUrl}
alt="event_emitter_eltex_loc"
className={NotificationBlockCss.event_emitter_eltex_loc_icon}
/>
<div className={NotificationBlockCss.text_box}>
<p className={NotificationBlockCss.text_box_text}>
{taskTitle}
{props.children}
</p>
</div>
</div>
);
};
export default NotificationBlock;
export default NotificationBlock;

View file

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

View file

@ -3,29 +3,48 @@ import TopBarCss from './top-bar.module.css';
import LogoImg from './event_emitter_eltex_loc-32px.png';
export type TopBarProps = {
contact: string;
children?: any;
contact: string;
children?: any;
};
const TopBar = (props: TopBarProps): ReactElement => {
return (
<div className={TopBarCss.top}>
<div className={TopBarCss.containerTitle}>
<div className={TopBarCss.logo}>
<a href="/" className={TopBarCss.logo}>
<img src={LogoImg} alt="event_emitter_eltex_loc" className={TopBarCss.eventEmitterEltexLoc} />
<span>redmine-issue-event-emitter</span>
</a>
</div>
{props.children}
return (
<div className={TopBarCss.top}>
<div className={TopBarCss.containerTitle}>
<div className={TopBarCss.logo}>
<a href="/" className={TopBarCss.logo}>
<img
src={LogoImg}
alt="event_emitter_eltex_loc"
className={TopBarCss.eventEmitterEltexLoc}
/>
<span>redmine-issue-event-emitter</span>
</a>
</div>
<p><a href="/" target="_blank"> #документация</a></p>
<p><a href={props.contact} target="_blank" rel="noreferrer"> #контакты</a></p>
<p><a href="https://gnedov.info/" target="_blank" rel="noreferrer"> #блог</a></p>
</div>
</div>
);
{props.children}
<p>
<a href="/" target="_blank">
{' '}
#документация
</a>
</p>
<p>
<a href={props.contact} target="_blank" rel="noreferrer">
{' '}
#контакты
</a>
</p>
<p>
<a href="https://gnedov.info/" target="_blank" rel="noreferrer">
{' '}
#блог
</a>
</p>
</div>
</div>
);
};
export default TopBar;

View file

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

View file

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

View file

@ -1,21 +1,31 @@
import axios from "axios";
import axios from 'axios';
import React from 'react';
export const onIssuesRefreshClick = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return;
e.stopPropagation();
const rawInput = prompt("Force issues refresh (delimiters - space, comma, semicolon or tab)", "");
if (!rawInput) return;
const list = rawInput.split(/[ ,;\t\n\r]/).map(item => Number(item)).filter(item => (Number.isFinite(item) && item > 0));
if (!list) return;
axios.post(`/redmine-event-emitter/append-issues`, list);
if (e.target !== e.currentTarget) return;
e.stopPropagation();
const rawInput = prompt(
'Force issues refresh (delimiters - space, comma, semicolon or tab)',
'',
);
if (!rawInput) return;
const list = rawInput
.split(/[ ,;\t\n\r]/)
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0);
if (!list) return;
axios.post(`/redmine-event-emitter/append-issues`, list);
};
export const onGetIssuesQueueSizeClick = async (e: React.MouseEvent): Promise<void> => {
if (e.target !== e.currentTarget) return;
e.stopPropagation();
const resp = await axios.get(`${process.env.REACT_APP_BACKEND}redmine-event-emitter/get-issues-queue-size`);
console.debug(`resp -`, resp); // DEBUG
if (!resp || typeof resp.data !== 'number') return;
alert(`Issues queue size - ${resp.data}`);
export const onGetIssuesQueueSizeClick = async (
e: React.MouseEvent,
): Promise<void> => {
if (e.target !== e.currentTarget) return;
e.stopPropagation();
const resp = await axios.get(
`${process.env.REACT_APP_BACKEND}redmine-event-emitter/get-issues-queue-size`,
);
console.debug(`resp -`, resp); // DEBUG
if (!resp || typeof resp.data !== 'number') return;
alert(`Issues queue size - ${resp.data}`);
};

View file

@ -1,11 +1,13 @@
/**
* Форматирование чисел для вывода трудозатрат
* @param a
* @returns
* @param a
* @returns
*/
export const SpentHoursToFixed = (a: number|string|null|undefined): string => {
if (a === null || typeof a === 'undefined') return '-';
const res = (typeof a === 'number') ? a : Number(a);
if (!Number.isFinite(res)) return '-';
return `${parseFloat(res.toFixed(1))}`;
};
export const SpentHoursToFixed = (
a: number | string | null | undefined,
): string => {
if (a === null || typeof a === 'undefined') return '-';
const res = typeof a === 'number' ? a : Number(a);
if (!Number.isFinite(res)) return '-';
return `${parseFloat(res.toFixed(1))}`;
};

View file

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

View file

@ -1,23 +1,23 @@
export function GetIssueReadingTimestamp(issueId: number): number {
const value = window.localStorage.getItem(getKey(issueId));
return value ? Number(value) : 0;
const value = window.localStorage.getItem(getKey(issueId));
return value ? Number(value) : 0;
}
export function SetIssueReadingTimestamp(issueId: number): number {
const now = (new Date()).getTime();
window.localStorage.setItem(getKey(issueId), String(now));
return now;
const now = new Date().getTime();
window.localStorage.setItem(getKey(issueId), String(now));
return now;
}
export function SetIssuesReadingTimestamp(issueIds: number[]): number {
const now = (new Date()).getTime();
for (let i = 0; i < issueIds.length; i++) {
const issueId = issueIds[i];
window.localStorage.setItem(getKey(issueId), String(now));
}
return now;
const now = new Date().getTime();
for (let i = 0; i < issueIds.length; i++) {
const issueId = issueIds[i];
window.localStorage.setItem(getKey(issueId), String(now));
}
return now;
}
function getKey(issueId: number): string {
return `issue_read_${issueId}`;
}
return `issue_read_${issueId}`;
}

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

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

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

View file

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

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

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

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

View file

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

View file

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