diff --git a/configs/issue-event-emitter-config.jsonc.dist b/configs/issue-event-emitter-config.jsonc.dist index d147a5b..3129594 100644 --- a/configs/issue-event-emitter-config.jsonc.dist +++ b/configs/issue-event-emitter-config.jsonc.dist @@ -43,7 +43,8 @@ "url": "", "dbs": { "users": "", - "issues": "" + "issues": "", + "dashboards": "" } } } \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2feaa5..f00f493 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index d5577bd..e0008f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/.eslintrc.js b/frontend/src/.eslintrc.js new file mode 100644 index 0000000..f6c62be --- /dev/null +++ b/frontend/src/.eslintrc.js @@ -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', + }, +}; diff --git a/frontend/src/dashboard/dashboard-page.tsx b/frontend/src/dashboard/dashboard-page.tsx new file mode 100644 index 0000000..4e77823 --- /dev/null +++ b/frontend/src/dashboard/dashboard-page.tsx @@ -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 ; +}; diff --git a/frontend/src/dashboard/dashboard-store.tsx b/frontend/src/dashboard/dashboard-store.tsx new file mode 100644 index 0000000..6342a05 --- /dev/null +++ b/frontend/src/dashboard/dashboard-store.tsx @@ -0,0 +1,154 @@ +import axios from 'axios'; +import { Instance, types } from 'mobx-state-tree'; + +type _WidgetParams = Record | null; + +export const WidgetParams = types.frozen<_WidgetParams>(); + +type _DataLoaderParams = Record | 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()), + 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; + +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; + +export async function DashboardLoadData(store: IDashboard): Promise { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/frontend/src/dashboard/dashboard.tsx b/frontend/src/dashboard/dashboard.tsx new file mode 100644 index 0000000..31bfa3d --- /dev/null +++ b/frontend/src/dashboard/dashboard.tsx @@ -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
Loading... {JSON.stringify(props.store)}
; + } + + const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false }); + + const widgets = props.store.data?.widgets.map((widget) => { + widget.setDashboardId(props.store.id); + return ; + }); + + 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 = ( +
+ + + Назад + Дашборд - {props.store.data?.title || props.store.id} + + + {widgets} +
+ ); + + return res; +}); diff --git a/frontend/src/dashboard/dashboards-list.tsx b/frontend/src/dashboard/dashboards-list.tsx new file mode 100644 index 0000000..8a2bd0b --- /dev/null +++ b/frontend/src/dashboard/dashboards-list.tsx @@ -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
Loading...
; + } + + return ( +
    + {props.store.list.map((item) => { + return ( +
  • + {item.title} +
  • + ); + })} +
+ ); +}); diff --git a/frontend/src/dashboard/dashboards-page.tsx b/frontend/src/dashboard/dashboards-page.tsx new file mode 100644 index 0000000..7a7620e --- /dev/null +++ b/frontend/src/dashboard/dashboards-page.tsx @@ -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 ( + <> + + + + ); +}; diff --git a/frontend/src/dashboard/dashboards-store.ts b/frontend/src/dashboard/dashboards-store.ts new file mode 100644 index 0000000..6c980da --- /dev/null +++ b/frontend/src/dashboard/dashboards-store.ts @@ -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; + +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; + +export async function ListStoreLoadData(store: IList): Promise { + const url = `${process.env.REACT_APP_BACKEND}api/dashboards`; + const resp = await axios.get(url); + if (!resp?.data) return; + store.setList(resp.data); +} diff --git a/frontend/src/dashboard/editor.module.css b/frontend/src/dashboard/editor.module.css new file mode 100644 index 0000000..d01f80c --- /dev/null +++ b/frontend/src/dashboard/editor.module.css @@ -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%; +} \ No newline at end of file diff --git a/frontend/src/dashboard/editor.tsx b/frontend/src/dashboard/editor.tsx new file mode 100644 index 0000000..c2953d5 --- /dev/null +++ b/frontend/src/dashboard/editor.tsx @@ -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; + +export async function LoadDashboard(dashboardId: string): Promise { + 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 { + const data = await LoadDashboard(store.dashboardId); + if (data) store.setData(data); +} + +export async function SaveDashboard( + dashboardId: string, + data: string, +): Promise { + 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 { + 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 ( +
+
+
+

+ + + Редактор дашборда +

+ setEditorValue(value || '')} + > +
+
+ Editor +
+ ); +}); diff --git a/frontend/src/dashboard/widget.tsx b/frontend/src/dashboard/widget.tsx new file mode 100644 index 0000000..214afdb --- /dev/null +++ b/frontend/src/dashboard/widget.tsx @@ -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 ( +
+
+ + Title - {props.store.title} +
+
+ +
+
+ ); +}); diff --git a/frontend/src/dashboard/widgets/issues-list.tsx b/frontend/src/dashboard/widgets/issues-list.tsx new file mode 100644 index 0000000..c607cb3 --- /dev/null +++ b/frontend/src/dashboard/widgets/issues-list.tsx @@ -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 ; +}); diff --git a/frontend/src/dashboard/widgets/kanban.tsx b/frontend/src/dashboard/widgets/kanban.tsx new file mode 100644 index 0000000..473fbfc --- /dev/null +++ b/frontend/src/dashboard/widgets/kanban.tsx @@ -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; +}; + +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 ; +}); diff --git a/frontend/src/dashboard/widgets/widget-factory.tsx b/frontend/src/dashboard/widgets/widget-factory.tsx new file mode 100644 index 0000000..df2ece7 --- /dev/null +++ b/frontend/src/dashboard/widgets/widget-factory.tsx @@ -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; +}; + +export const WidgetFactory = observer((props: Props): JSX.Element => { + const type = props.store.type; + + if (type.startsWith('kanban_by_')) { + return ; + } + + if (type.startsWith('issues_list_')) { + return ; + } + + return ( +
+
Unknown widget
+ +
+ ); +}); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 032464f..07924fc 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -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( - + , ); // If you want to start measuring performance in your app, pass a function diff --git a/frontend/src/issues-list-board/issues-list-board.tsx b/frontend/src/issues-list-board/issues-list-board.tsx index eb51b8c..66da274 100644 --- a/frontend/src/issues-list-board/issues-list-board.tsx +++ b/frontend/src/issues-list-board/issues-list-board.tsx @@ -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 ( - - ); - }); - let title: JSX.Element; - if (props.store.metainfo.url) { - title = {props.store.metainfo.title}; - } else { - title = <>{props.store.metainfo.title}; - } - return ( -
-
-

{title}

- - anchor - -
-
- {list} -
-
- ); -}); \ No newline at end of file + 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( + , + ); + } + } + let title: JSX.Element; + if (props.store.metainfo.url) { + title = {props.store.metainfo.title}; + } else { + title = <>{props.store.metainfo.title}; + } + return ( +
+
+

+ {title} +

+ + anchor + +
+
{list}
+
+ ); +}); diff --git a/frontend/src/issues-list-board/issues-list-boards-page.tsx b/frontend/src/issues-list-board/issues-list-boards-page.tsx index 531ad27..702d5fc 100644 --- a/frontend/src/issues-list-board/issues-list-boards-page.tsx +++ b/frontend/src/issues-list-board/issues-list-boards-page.tsx @@ -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 ( - - ); -}; \ No newline at end of file + 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 ; +}; diff --git a/frontend/src/issues-list-board/issues-list-boards.tsx b/frontend/src/issues-list-board/issues-list-boards.tsx index c9250bb..18ac96a 100644 --- a/frontend/src/issues-list-board/issues-list-boards.tsx +++ b/frontend/src/issues-list-board/issues-list-boards.tsx @@ -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
Loading...
- } - const list: any[] = []; - for (let i = 0; i < data.length; i++) { - const boardData = data[i]; - const key = boardData.metainfo.title; - const board = - 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 ( - <> - - - - - - {list} - - ); -}); \ No newline at end of file + const data = props.store.data; + if (!props.store.loaded || !data) { + return
Loading...
; + } + const list: any[] = []; + for (let i = 0; i < data.length; i++) { + const boardData = data[i]; + const key = boardData.metainfo.title; + const board = ( + + ); + 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 ( + <> + + + + + + {list} + + ); +}); diff --git a/frontend/src/issues-list-board/issues-list-card.tsx b/frontend/src/issues-list-board/issues-list-card.tsx index 242546d..6e52c1b 100644 --- a/frontend/src/issues-list-board/issues-list-card.tsx +++ b/frontend/src/issues-list-board/issues-list-card.tsx @@ -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) ?
: null; - return ( -
{ e.stopPropagation(); detailsStore.show(); }}> - -
- -
-
- - - - - {SpentHoursToFixed(props.store.total_spent_hours)} / {SpentHoursToFixed(props.store.total_estimated_hours)} - {tagsNewLine} - -
- {props.store.status.name} - {props.store.priority.name} -
-
-
- ); -}); \ No newline at end of file + 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 ?
: null; + return ( +
{ + e.stopPropagation(); + detailsStore.show(); + }} + > + +
+ +
+
+ + + + + + {SpentHoursToFixed(props.store.total_spent_hours)} /{' '} + {SpentHoursToFixed(props.store.total_estimated_hours)} + + {tagsNewLine} + +
+ {props.store.status.name} + + + {props.store?.priority?.name} + +
+
+
+ ); +}); diff --git a/frontend/src/issues-list-board/store.ts b/frontend/src/issues-list-board/store.ts index 2442ebf..0d5de8f 100644 --- a/frontend/src/issues-list-board/store.ts +++ b/frontend/src/issues-list-board/store.ts @@ -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(); -export interface IIssueStore extends Instance {} +export type IIssueStore = Instance; 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 {} +export type IBoardStore = Instance; -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 { - 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}; - 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 }; + 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 {} \ No newline at end of file +export type IPageStore = Instance; diff --git a/frontend/src/kanban-board/column.tsx b/frontend/src/kanban-board/column.tsx index 0694ca8..3c70614 100644 --- a/frontend/src/kanban-board/column.tsx +++ b/frontend/src/kanban-board/column.tsx @@ -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 ( - - ); - }); - return ( -
-
- {props.store.status} ({props.store.count}) -
- {cards} -
- ); + const cards = props.store.cards.map((card) => { + return ( + + ); + }); + return ( +
+
+ {props.store.status} ({props.store.count}) +
+ {cards} +
+ ); }); export default Column; diff --git a/frontend/src/kanban-board/kanban-board.tsx b/frontend/src/kanban-board/kanban-board.tsx index e90c13c..c96ef6e 100644 --- a/frontend/src/kanban-board/kanban-board.tsx +++ b/frontend/src/kanban-board/kanban-board.tsx @@ -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 = {props.store.metainfo.title}; - } 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() - } - return ( - <> -

{title} #

-
- {columns} -
- - ); + let title: any; + if (props.store.metainfo.url) { + title = {props.store.metainfo.title}; + } 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(); + } + return ( + <> +

+ {title} # +

+
{columns}
+ + ); }); -export default KanbanBoard; \ No newline at end of file +export default KanbanBoard; diff --git a/frontend/src/kanban-board/kanban-boards-page.tsx b/frontend/src/kanban-board/kanban-boards-page.tsx index f9ba580..ca716e0 100644 --- a/frontend/src/kanban-board/kanban-boards-page.tsx +++ b/frontend/src/kanban-board/kanban-boards-page.tsx @@ -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 ; -} \ No newline at end of file + 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 ; +}; diff --git a/frontend/src/kanban-board/kanban-boards.tsx b/frontend/src/kanban-board/kanban-boards.tsx index b019b0f..acb1898 100644 --- a/frontend/src/kanban-board/kanban-boards.tsx +++ b/frontend/src/kanban-board/kanban-boards.tsx @@ -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
Loading...
- } - const list: any[] = []; - for (let i = 0; i < data.length; i++) { - const boardData = data[i]; - const key = boardData.metainfo.title; - const board = ; - 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 = ; - } - return ( - <> - - - {treeRefreshMenuItem} - - - - {list} - - ); + const data = props.store.data; + if (!props.store.loaded || !data) { + return
Loading...
; + } + const list: any[] = []; + for (let i = 0; i < data.length; i++) { + const boardData = data[i]; + const key = boardData.metainfo.title; + const board = ; + 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 = ( + + ); + } + return ( + <> + + + {treeRefreshMenuItem} + + + + {list} + + ); }); -export default KanbanBoards; \ No newline at end of file +export default KanbanBoards; diff --git a/frontend/src/kanban-board/kanban-card.tsx b/frontend/src/kanban-board/kanban-card.tsx index 16f7b0e..113c015 100644 --- a/frontend/src/kanban-board/kanban-card.tsx +++ b/frontend/src/kanban-board/kanban-card.tsx @@ -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 ( - - {props.tag} - - ); -} + const inlineStyle = getStyleObjectFromString(props.style || ''); + return ( + + {props.tag} + + ); +}; /** * Какие дальше требования к карточкам? - * + * * 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 = - } - 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 ( -
{e.stopPropagation(); detailsStore.show();}}> - - -
Исп.: {props.store.issue.current_user.name}
-
Прио.: {props.store.issue.priority.name}
-
Версия: {props.store.issue.fixed_version?.name || ''}
-
Прогресс: {props.store.issue.done_ratio}
-
Трудозатраты: {props.store.issue.total_spent_hours} / {props.store.issue.total_estimated_hours}
- {tagsSection} -
- ); + 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 = ; + } + 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 ( +
{ + e.stopPropagation(); + detailsStore.show(); + }} + > + + +
Исп.: {props.store.issue.current_user.name}
+
Прио.: {props.store.issue.priority.name}
+
Версия: {props.store.issue.fixed_version?.name || ''}
+
Прогресс: {props.store.issue.done_ratio}
+
+ Трудозатраты: {props.store.issue.total_spent_hours} /{' '} + {props.store.issue.total_estimated_hours} +
+ {tagsSection} +
+ ); }); -export default KanbanCard; \ No newline at end of file +export default KanbanCard; diff --git a/frontend/src/kanban-board/store.ts b/frontend/src/kanban-board/store.ts index 1a16812..2fb1600 100644 --- a/frontend/src/kanban-board/store.ts +++ b/frontend/src/kanban-board/store.ts @@ -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() +export const IssueStore = types.frozen(); -export interface IIssueStore extends Instance {} +export type IIssueStore = Instance; -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 {} +export type IColumnStore = Instance; 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 {} +export type IMetaInfoStore = Instance; export const BoardStore = types.model({ - data: types.array(ColumnStore), - metainfo: MetaInfoStore + data: types.array(ColumnStore), + metainfo: MetaInfoStore, }); -export interface IBoardStore extends Instance {} +export type IBoardStore = Instance; -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 { - 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 { } +export type IPageStore = Instance; export type CardField = { - component: string; + component: string; } & Record; export const CardParamsStore = types.optional( - types.model({ - fields: types.array( - types.frozen() - ), - 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()), + 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 {} - +export type ICardStore = Instance; diff --git a/frontend/src/misc-components/debug-info.module.css b/frontend/src/misc-components/debug-info.module.css new file mode 100644 index 0000000..f429782 --- /dev/null +++ b/frontend/src/misc-components/debug-info.module.css @@ -0,0 +1,5 @@ +.debugInfo { + margin: 3px; + padding: 3px; + border: 1px solid black; +} \ No newline at end of file diff --git a/frontend/src/misc-components/debug-info.tsx b/frontend/src/misc-components/debug-info.tsx new file mode 100644 index 0000000..de71c46 --- /dev/null +++ b/frontend/src/misc-components/debug-info.tsx @@ -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 =
{props.value}
; + } else if (props.children) { + output = props.children; + } else { + output =
(none)
; + } + return
{output}
; +}; diff --git a/frontend/src/misc-components/issue-details-dialog.tsx b/frontend/src/misc-components/issue-details-dialog.tsx index 3a9e841..f8958ec 100644 --- a/frontend/src/misc-components/issue-details-dialog.tsx +++ b/frontend/src/misc-components/issue-details-dialog.tsx @@ -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(), - 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(), + 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 + store: Instance; }; 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 ( -
-
-
-

- - - -

-
-
-

Описание:

-
-							{props.store.issue.description}
-						
-
-
-
-

Комментарии:

- -
-
-
-
- ); + 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 ( +
+
+
+

+ + + +

+
+
+

Описание:

+
{props.store.issue.description}
+
+
+
+

Комментарии:

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

{date} {props.data.user.name}:

-
-
-					{props.data.notes || '-'}
-				
-
-
- - ); -} \ No newline at end of file +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 ( + <> +

+ {date} {props.data.user.name}: +

+
+
{props.data.notes || '-'}
+
+
+ + ); +}; diff --git a/frontend/src/misc-components/issue-href.tsx b/frontend/src/misc-components/issue-href.tsx index 6d9aad9..aab7cdc 100644 --- a/frontend/src/misc-components/issue-href.tsx +++ b/frontend/src/misc-components/issue-href.tsx @@ -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 ( - {props.tracker} #{props.id} - {props.subject} - ); -}; \ No newline at end of file + return ( + + {props.tracker} #{props.id} - {props.subject} + + ); +}; diff --git a/frontend/src/misc-components/tag.tsx b/frontend/src/misc-components/tag.tsx index 452dff9..2fd3d1a 100644 --- a/frontend/src/misc-components/tag.tsx +++ b/frontend/src/misc-components/tag.tsx @@ -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 ( - <> - - - {props.tag} - - - ); -} \ No newline at end of file + const inlineStyle = getStyleObjectFromString(props.style || ''); + return ( + <> + + + {props.tag} + + + ); +}; diff --git a/frontend/src/misc-components/tags.tsx b/frontend/src/misc-components/tags.tsx index ae749ab..82aeaa2 100644 --- a/frontend/src/misc-components/tags.tsx +++ b/frontend/src/misc-components/tags.tsx @@ -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 ; - }) || []; - return ( - <> - {label}{tags} - - ); -} \ No newline at end of file + if (!props.params.tags) { + return <>; + } + let label = props.params.label || ''; + if (label) label = `${label}: `; + const tags = + props.params.tags.map((tag) => { + return ; + }) || []; + return ( + <> + {label} + {tags} + + ); +}; diff --git a/frontend/src/misc-components/time-passed.tsx b/frontend/src/misc-components/time-passed.tsx index 0562b2c..b7731d6 100644 --- a/frontend/src/misc-components/time-passed.tsx +++ b/frontend/src/misc-components/time-passed.tsx @@ -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 ( - - ); + 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 ; }; 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 ''; - } -} \ No newline at end of file + 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 ''; + } +} diff --git a/frontend/src/misc-components/top-right-menu.tsx b/frontend/src/misc-components/top-right-menu.tsx index 5bdc5d7..7e19926 100644 --- a/frontend/src/misc-components/top-right-menu.tsx +++ b/frontend/src/misc-components/top-right-menu.tsx @@ -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; - children?: any; + store: Instance; + 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(
  • {item}
  • ); - } - } else if (props.children) { - menuItems.push(
  • {props.children}
  • ) - } - return ( - <> - -
    -
      - {menuItems} -
    -
    - - ); -}) \ No newline at end of file + const menuItems = []; + if (props.children.length > 1) { + for (let key = 0; key < props.children.length; key++) { + const item = props.children[key]; + menuItems.push(
  • {item}
  • ); + } + } else if (props.children) { + menuItems.push(
  • {props.children}
  • ); + } + return ( + <> + +
    +
      {menuItems}
    +
    + + ); +}); diff --git a/frontend/src/misc-components/unreaded-flag.tsx b/frontend/src/misc-components/unreaded-flag.tsx index f87da53..81432ba 100644 --- a/frontend/src/misc-components/unreaded-flag.tsx +++ b/frontend/src/misc-components/unreaded-flag.tsx @@ -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(), - 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(), + 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 -} + store: Instance; +}; export const UnreadedFlag = observer((props: Props): JSX.Element => { - const className = props.store.getClassName(); - return ( - {e.stopPropagation(); props.store.read();}}> - ); -}) \ No newline at end of file + const className = props.store.getClassName(); + return ( + { + e.stopPropagation(); + props.store.read(); + }} + > + ); +}); diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 890097e..c9642c0 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: (), + { + path: '/', + element: , }, - { - path: "/kanban-board/:type/:name", - element: () - }, - { - path: "/issues-list-board/:type/:name", - element: () - }, - { - path: "*", - element: () - } -]); \ No newline at end of file + { + path: '/kanban-board/:type/:name', + element: , + }, + { + path: '/issues-list-board/:type/:name', + element: , + }, + { + path: '/dashboards', + element: , + }, + { + path: '/dashboard/:id', + element: , + }, + { + path: '*', + element: , + }, +]); diff --git a/frontend/src/start-page/basement.tsx b/frontend/src/start-page/basement.tsx index 1bf12e4..9a402eb 100644 --- a/frontend/src/start-page/basement.tsx +++ b/frontend/src/start-page/basement.tsx @@ -10,18 +10,29 @@ export type Props = { export const Basement = (props: Props): JSX.Element => { console.debug('routes:', router.routes); // DEBUG - + return (
    @@ -30,8 +41,12 @@ export const Basement = (props: Props): JSX.Element => {

    ОБСУДИТЬ

    - Сharacter - + Сharacter
    diff --git a/frontend/src/start-page/content-block.tsx b/frontend/src/start-page/content-block.tsx index 1a53e54..4fc9e25 100644 --- a/frontend/src/start-page/content-block.tsx +++ b/frontend/src/start-page/content-block.tsx @@ -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 ( - <> -

    {props.title}

    - {props.children} - - ); + return ( + <> +

    {props.title}

    + {props.children} + + ); }; -export default ContentBlock; \ No newline at end of file +export default ContentBlock; diff --git a/frontend/src/start-page/content.tsx b/frontend/src/start-page/content.tsx index b9c96aa..9dd70a9 100644 --- a/frontend/src/start-page/content.tsx +++ b/frontend/src/start-page/content.tsx @@ -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 ( -
    - {props.children} -
    - ); + return
    {props.children}
    ; }; -export default Content; \ No newline at end of file +export default Content; diff --git a/frontend/src/start-page/cover.tsx b/frontend/src/start-page/cover.tsx index ede82cb..ff27564 100644 --- a/frontend/src/start-page/cover.tsx +++ b/frontend/src/start-page/cover.tsx @@ -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 ( -
    - Сharacter -
    -

    Redmine Issue Event Emitter

    -

    ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"

    -

    - ссылка на телеграмм бота -

    -
    -
    - ); + return ( +
    + Сharacter +
    +

    Redmine Issue Event Emitter

    +

    ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"

    +

    + ссылка на телеграмм бота +

    +
    +
    + ); }; -export default Cover; \ No newline at end of file +export default Cover; diff --git a/frontend/src/start-page/notification-block.tsx b/frontend/src/start-page/notification-block.tsx index 1956af1..76096d4 100644 --- a/frontend/src/start-page/notification-block.tsx +++ b/frontend/src/start-page/notification-block.tsx @@ -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 - ? ({props.taskTitle} ) - : (<>); - return ( -
    - event_emitter_eltex_loc -
    -

    - {taskTitle} - {props.children} -

    -
    -
    - ); + const taskTitle = props?.taskTitle ? ( + + {props.taskTitle}{' '} + + ) : ( + <> + ); + return ( +
    + event_emitter_eltex_loc +
    +

    + {taskTitle} + {props.children} +

    +
    +
    + ); }; -export default NotificationBlock; \ No newline at end of file +export default NotificationBlock; diff --git a/frontend/src/start-page/start-page.tsx b/frontend/src/start-page/start-page.tsx index a054d79..896599b 100644 --- a/frontend/src/start-page/start-page.tsx +++ b/frontend/src/start-page/start-page.tsx @@ -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 ( -
    - - - - -
      -
    • Уведомления в реальном времени о событиях из задач - изменения статусов, упоминания комментариев
    • -
    • Генерация и управления отчётами о задачах
    • -
    • Под капотом приложение фреймворк
    • -
    -
    - -
      -
    • Последний отчёт для дейли проект ECCM
    • -
    • Дополнительные функции для разработчиков - eccm:/current_issues_eccm - список текущих задач по статусам - выбираютсятолько задачи из актуальных версий в статусах, где нужна какая-то реакцияили возможна работа прямо сейчас
    • -
    • Скриншоты уведомления от бота: - Примеры уведомлений о новых задачах и об изменениях статусов:
    • -
    - - - Реализовать поддержку нового протокола:

    - Стив Джобс изменил статус задачи с Feedback на Closed -
    - - - Добавить поддержку новых моделей:

    + return ( +
    + + + + +
      +
    • + Уведомления в реальном времени о событиях из задач - изменения + статусов, упоминания комментариев +
    • +
    • Генерация и управления отчётами о задачах
    • +
    • Под капотом приложение фреймворк
    • +
    +
    + +
      +
    • Последний отчёт для дейли проект ECCM
    • +
    • + Дополнительные функции для разработчиков eccm:/current_issues_eccm + - список текущих задач по статусам - выбираютсятолько задачи из + актуальных версий в статусах, где нужна какая-то реакцияили + возможна работа прямо сейчас +
    • +
    • + Скриншоты уведомления от бота: Примеры уведомлений о новых задачах + и об изменениях статусов: +
    • +
    - Билл Гейтс создал новую задачу и назначил её на вас - - -

    Простые уведомления о движении задач - и больше ничего лишнего. - Пример уведомления по личному упоминанию в задаче: -

    - - - Сергей Брин:

    + + Реализовать поддержку нового протокола:
    +
    + Стив Джобс изменил статус задачи с Feedback на Closed +
    - @Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по описанию к этой задаче. -
    - - - Исправление уязвимости

    + + Добавить поддержку новых моделей:
    +
    + Билл Гейтс создал новую задачу и назначил её на вас +
    - Линус Торвальдс завершил разработку по задаче и передал вам на ревью

    +

    + Простые уведомления о движении задач - и больше ничего лишнего. + Пример уведомления по личному упоминанию в задаче: +

    - Кажется получилось поправить проблемку. Глянь мой MR. -
    - -

    Можно задавать коллегам вопросы прямо из комментария задачи, неотрываясь от её содержимого. Уведомление доставится в считанные минуты с ссылкой на задачу и информацией от кого это уведомление.

    -

    Пример запроса моих текущих задач с помощью команды - /current_issues_eccm -

    - - - Бьёрн Страуструп:

    + + Сергей Брин: +
    +
    + @Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по + описанию к этой задаче. +
    - Re-opened:

    - - Feature #223301: - Дополнить stdlib новыми функциями (прио - P4, версия - C++23)

    - In Progress:

    - - Question #223411: - Выпуск релиза C++23 (прио - P4, версия - C++23) -
    -
    -
    - -
    - ); + + Исправление уязвимости +
    +
    + Линус Торвальдс завершил разработку по задаче и передал вам на ревью +
    +
    + Кажется получилось поправить проблемку. Глянь мой MR. +
    + +

    + Можно задавать коллегам вопросы прямо из комментария задачи, + неотрываясь от её содержимого. Уведомление доставится в считанные + минуты с ссылкой на задачу и информацией от кого это уведомление. +

    +

    + Пример запроса моих текущих задач с помощью команды + + /current_issues_eccm + +

    + + + Бьёрн Страуструп: +
    +
    + Re-opened: +
    +
    + + {' '} + - Feature #223301:{' '} + + Дополнить stdlib новыми функциями (прио - P4, версия - C++23) +
    +
    + In Progress: +
    +
    + + {' '} + - Question #223411: + + Выпуск релиза C++23 (прио - P4, версия - C++23) +
    +
    +
    + +
    + ); }; -export default StartPage; \ No newline at end of file +export default StartPage; diff --git a/frontend/src/start-page/top-bar.tsx b/frontend/src/start-page/top-bar.tsx index 365dcee..83588ca 100644 --- a/frontend/src/start-page/top-bar.tsx +++ b/frontend/src/start-page/top-bar.tsx @@ -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 ( - + ); }; export default TopBar; diff --git a/frontend/src/unknown-page.tsx b/frontend/src/unknown-page.tsx index 325975f..b00db7c 100644 --- a/frontend/src/unknown-page.tsx +++ b/frontend/src/unknown-page.tsx @@ -1,9 +1,7 @@ import React from 'react'; export const UnknownPage = () => { - return ( -

    Unknown page

    - ) + return

    Unknown page

    ; }; -export default UnknownPage; \ No newline at end of file +export default UnknownPage; diff --git a/frontend/src/utils/service-actions-buttons.tsx b/frontend/src/utils/service-actions-buttons.tsx index 16c1706..6f8cadd 100644 --- a/frontend/src/utils/service-actions-buttons.tsx +++ b/frontend/src/utils/service-actions-buttons.tsx @@ -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 ( - - ); + return ; }; export const GetIssuesQueueSizeButton = (): JSX.Element => { - return ( - - ); + return ( + + ); }; diff --git a/frontend/src/utils/service-actions.ts b/frontend/src/utils/service-actions.ts index 0c2f950..b0298ac 100644 --- a/frontend/src/utils/service-actions.ts +++ b/frontend/src/utils/service-actions.ts @@ -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 => { - 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 => { + 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}`); }; diff --git a/frontend/src/utils/spent-hours-to-fixed.ts b/frontend/src/utils/spent-hours-to-fixed.ts index a7e8045..0607f52 100644 --- a/frontend/src/utils/spent-hours-to-fixed.ts +++ b/frontend/src/utils/spent-hours-to-fixed.ts @@ -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))}`; -}; \ No newline at end of file +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))}`; +}; diff --git a/frontend/src/utils/style.ts b/frontend/src/utils/style.ts index 0b675d2..1908318 100644 --- a/frontend/src/utils/style.ts +++ b/frontend/src/utils/style.ts @@ -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 => { - const style = {} as Record; - str.split(";").forEach(el => { - const [property, value] = el.split(":"); - if (!property) return; +export const getStyleObjectFromString = ( + str: string, +): Record => { + if (!str) return {}; + const style = {} as Record; + 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; -}; \ No newline at end of file + return style; +}; diff --git a/frontend/src/utils/unreaded-provider.ts b/frontend/src/utils/unreaded-provider.ts index 2bf4299..9adc8b7 100644 --- a/frontend/src/utils/unreaded-provider.ts +++ b/frontend/src/utils/unreaded-provider.ts @@ -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}`; -} \ No newline at end of file + return `issue_read_${issueId}`; +} diff --git a/libs/event-emitter/src/couchdb-datasources/dashboards.ts b/libs/event-emitter/src/couchdb-datasources/dashboards.ts new file mode 100644 index 0000000..80aa5de --- /dev/null +++ b/libs/event-emitter/src/couchdb-datasources/dashboards.ts @@ -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 + > { + 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> { + return await Dashboards.getDatasource(); + } +} diff --git a/libs/event-emitter/src/dashboards/dashboard.controller.ts b/libs/event-emitter/src/dashboards/dashboard.controller.ts new file mode 100644 index 0000000..980c234 --- /dev/null +++ b/libs/event-emitter/src/dashboards/dashboard.controller.ts @@ -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 { + const res = await getOrAppErrorOrThrow( + () => this.dashboardsService.create(), + BadRequestErrorHandler, + ); + return res.id; + } + + @Get(':id') + async load(@Param('id') id: string): Promise { + const res = await getOrAppErrorOrThrow( + () => this.dashboardsService.load(id), + BadRequestErrorHandler, + ); + return res; + } + + @Get(':id/load-data') + async loadData(@Param('id') id: string): Promise { + return await getOrAppErrorOrThrow( + () => this.dashboardsDataService.loadData(id), + BadRequestErrorHandler, + ); + } + + @Get(':id/load-data/:widgetId') + async loadDataForWidget( + @Param('id') id: string, + @Param('widgetId') widgetId: string, + ): Promise { + return await getOrAppErrorOrThrow( + () => this.dashboardsDataService.loadDataForWidget(id, widgetId), + BadRequestErrorHandler, + ); + } + + @Put(':id') + async save(@Param('id') id: string, @Body() data: any): Promise { + const res = await getOrAppErrorOrThrow( + () => this.dashboardsService.save(id, data), + BadRequestErrorHandler, + ); + return res; + } +} diff --git a/libs/event-emitter/src/dashboards/dashboards-data.service.ts b/libs/event-emitter/src/dashboards/dashboards-data.service.ts new file mode 100644 index 0000000..f240bd1 --- /dev/null +++ b/libs/event-emitter/src/dashboards/dashboards-data.service.ts @@ -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 { + 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 { + 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> { + 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; + } +} diff --git a/libs/event-emitter/src/dashboards/dashboards.controller.ts b/libs/event-emitter/src/dashboards/dashboards.controller.ts new file mode 100644 index 0000000..84a3e1a --- /dev/null +++ b/libs/event-emitter/src/dashboards/dashboards.controller.ts @@ -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 { + const res = await getOrAppErrorOrThrow( + () => this.dashboardsService.publicList(UNLIMITED), + BadRequestErrorHandler, + ); + return res; + } +} diff --git a/libs/event-emitter/src/dashboards/dashboards.service.ts b/libs/event-emitter/src/dashboards/dashboards.service.ts new file mode 100644 index 0000000..425cde7 --- /dev/null +++ b/libs/event-emitter/src/dashboards/dashboards.service.ts @@ -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 { + 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 { + 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 { + this.logger.debug(`Load dashboard id - ${id}`); + const rawData = await this.loadRawData(id); + return rawData?.data || { widgets: [] }; + } + + async isExists(id: string): Promise { + 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 { + 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 })); + } +} diff --git a/libs/event-emitter/src/dashboards/interactive-widget-factory.ts b/libs/event-emitter/src/dashboards/interactive-widget-factory.ts new file mode 100644 index 0000000..1125f6e --- /dev/null +++ b/libs/event-emitter/src/dashboards/interactive-widget-factory.ts @@ -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 +{ + constructor( + public dataLoader: WidgetDataLoaderInterface, + public type: string, + ) {} + + async render( + widgetParams: any, + dataLoaderParams: any, + dashboardParams: any, + ): Promise> { + const data = await this.dataLoader.load(dataLoaderParams, dashboardParams); + return data.error ? fail(data.error) : success(data.result); + } +} + +export function createInteractiveWidget( + dataLoader: WidgetDataLoaderInterface, + type: string, +): WidgetInterface { + return new InteractiveWidget(dataLoader, type); +} diff --git a/libs/event-emitter/src/dashboards/text-widget-factory.ts b/libs/event-emitter/src/dashboards/text-widget-factory.ts new file mode 100644 index 0000000..7e841c0 --- /dev/null +++ b/libs/event-emitter/src/dashboards/text-widget-factory.ts @@ -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 { + constructor( + public dataLoader: WidgetDataLoaderInterface, + public type: string, + public template: string, + ) {} + + async render( + widgetParams: any, + dataLoaderParams: any, + dashboardParams: any, + ): Promise> { + const params = { + widgetParams, + dataLoaderParams, + dashboardParams, + }; + const template = Handlebars.compile(this.template); + const res = template(params); + return success(res); + } +} + +export function createTextWidget( + dataLoader: WidgetDataLoaderInterface, + type: string, + template: string, +): WidgetInterface { + return new TextWidget(dataLoader, type, template); +} diff --git a/libs/event-emitter/src/dashboards/widget-data-loader-interface.ts b/libs/event-emitter/src/dashboards/widget-data-loader-interface.ts new file mode 100644 index 0000000..3608fc7 --- /dev/null +++ b/libs/event-emitter/src/dashboards/widget-data-loader-interface.ts @@ -0,0 +1,9 @@ +import { AppError, Result } from '../utils/result'; + +export interface WidgetDataLoaderInterface { + isMyConfig(dataLoaderParams: DLP): boolean; + load( + dataLoaderParams: DLP, + dashboardParams: DBP, + ): Promise>; +} diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts b/libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service.ts similarity index 89% rename from libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts rename to libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service.ts index 3716036..7414c77 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-fields.widget.service.ts +++ b/libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service.ts @@ -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 +export class ListIssuesByFieldsWidgetDataLoaderService + implements WidgetDataLoaderInterface { - 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 { + async load(widgetParams: Params): Promise> { 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 { diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts b/libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-users-like-jira.widget-data-loader.service.ts similarity index 88% rename from libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts rename to libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-users-like-jira.widget-data-loader.service.ts index d31eb6e..bd14553 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users-like-jira.widget.service.ts +++ b/libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-users-like-jira.widget-data-loader.service.ts @@ -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 +export class ListIssuesByUsersLikeJiraWidgetDataLoaderService + implements WidgetDataLoaderInterface { - 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 { + async load(widgetParams: Params): Promise> { 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 { diff --git a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts b/libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-users.widget-data-loader.service.ts similarity index 89% rename from libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts rename to libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-users.widget-data-loader.service.ts index 4ba3cbd..6180254 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/list-issues-by-users.widget.service.ts +++ b/libs/event-emitter/src/dashboards/widget-data-loader/list-issues-by-users.widget-data-loader.service.ts @@ -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; type FindResult = ListIssuesByUsersWidgetNs.Models.FindResult; @Injectable() -export class ListIssuesByUsersWidgetService - implements WidgetInterface +export class ListIssuesByUsersWidgetDataLoaderService + implements WidgetDataLoaderInterface { - 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 { + async load(widgetParams: Params): Promise> { 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 { diff --git a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts b/libs/event-emitter/src/dashboards/widget-data-loader/root-issue-subtrees.widget-data-loader.service.ts similarity index 88% rename from libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts rename to libs/event-emitter/src/dashboards/widget-data-loader/root-issue-subtrees.widget-data-loader.service.ts index 94b7974..6288ee5 100644 --- a/libs/event-emitter/src/project-dashboard/widgets/root-issue-subtrees.widget.service.ts +++ b/libs/event-emitter/src/dashboards/widget-data-loader/root-issue-subtrees.widget-data-loader.service.ts @@ -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 +export class RootIssueSubTreesWidgetDataLoaderService + implements WidgetDataLoaderInterface { private issuesLoader: IssuesServiceNs.IssuesLoader; @@ -56,7 +57,7 @@ export class RootIssueSubTreesWidgetService return true; } - async render(widgetParams: Params): Promise { + async load(widgetParams: Params): Promise> { 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); } } diff --git a/libs/event-emitter/src/dashboards/widget-interface.ts b/libs/event-emitter/src/dashboards/widget-interface.ts new file mode 100644 index 0000000..4fdd2c1 --- /dev/null +++ b/libs/event-emitter/src/dashboards/widget-interface.ts @@ -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 { + dataLoader: WidgetDataLoaderInterface; + type: string; + render( + widgetParams: WP, + dataLoaderParams: DLP, + dashboardParams: DBP, + ): Promise>; +} diff --git a/libs/event-emitter/src/dashboards/widgets-collection.service.ts b/libs/event-emitter/src/dashboards/widgets-collection.service.ts new file mode 100644 index 0000000..a09685a --- /dev/null +++ b/libs/event-emitter/src/dashboards/widgets-collection.service.ts @@ -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[] = []; + + 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, + ): Result { + 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, string> { + const widget = this.collection.find((w) => w.type === type); + return widget ? success(widget) : fail('WIDGET_WITH_SAME_TYPE_NOT_FOUND'); + } +} diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index 7383ec6..5aef956 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -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], }; } diff --git a/libs/event-emitter/src/models/dashboard.ts b/libs/event-emitter/src/models/dashboard.ts new file mode 100644 index 0000000..fc07929 --- /dev/null +++ b/libs/event-emitter/src/models/dashboard.ts @@ -0,0 +1,28 @@ +export type Data = { + widgets: Widget[]; + title?: string; +} | null; + +export type Dashboard = { + id: string; + data: Data; +}; + +/** + * Параметры для отрисовки данных + */ +export type WidgetParams = Record | null; + +/** + * Параметры для загрузки данных + */ +export type DataLoaderParams = Record | null; + +export type Widget = { + type: string; + id: string; + title: string; + collapsed?: boolean; + widgetParams?: WidgetParams; + dataLoaderParams?: DataLoaderParams; +}; diff --git a/libs/event-emitter/src/models/main-config-model.ts b/libs/event-emitter/src/models/main-config-model.ts index b1c1342..3e5088a 100644 --- a/libs/event-emitter/src/models/main-config-model.ts +++ b/libs/event-emitter/src/models/main-config-model.ts @@ -16,6 +16,7 @@ export type MainConfigModel = { dbs: { users: string; issues: string; + dashboards: string; }; }; webhooks: WebhookConfigItemModel[]; diff --git a/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts b/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts deleted file mode 100644 index 78446ff..0000000 --- a/libs/event-emitter/src/project-dashboard/project-dashboard.service.ts +++ /dev/null @@ -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; - }; - export type AnyFilterParams = Tree | List | DynamicList | Version; - } - - export type FilterParams = Record; - - 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[]; - - 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; - } -} diff --git a/libs/event-emitter/src/project-dashboard/widget-interface.ts b/libs/event-emitter/src/project-dashboard/widget-interface.ts deleted file mode 100644 index 2f00e79..0000000 --- a/libs/event-emitter/src/project-dashboard/widget-interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface WidgetInterface { - isMyConfig(widgetParams: W): boolean; - render(widgetParams: W, dashboardParams: D): Promise; -} diff --git a/libs/event-emitter/src/utils/result.ts b/libs/event-emitter/src/utils/result.ts new file mode 100644 index 0000000..fef53a4 --- /dev/null +++ b/libs/event-emitter/src/utils/result.ts @@ -0,0 +1,74 @@ +import { BadRequestException } from '@nestjs/common'; + +export type Result = { + result?: T; + error?: E; +}; + +export function success(res: T): Result { + return { + result: res, + }; +} + +export function fail(error: E): Result { + return { + error: error, + }; +} + +export function getOrThrow(res: Result): T { + if (res.result) return res.result; + throw res.error ? res.error : 'UNKNOWN_ERROR'; +} + +export async function successOrError( + cb: () => Promise, +): Promise> { + 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( + fn: () => Promise, + onAppError?: (err: Error) => Error, + onOtherError?: (err: Error) => Error, +): Promise { + 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); +} diff --git a/src/app.module.ts b/src/app.module.ts index 2afa718..077e588 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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(); + } } diff --git a/src/dashboards/dashboard-init.service.ts b/src/dashboards/dashboard-init.service.ts new file mode 100644 index 0000000..cb3e7f1 --- /dev/null +++ b/src/dashboards/dashboard-init.service.ts @@ -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)); + } +} diff --git a/src/dashboards/simple-issues-list.controller.ts b/src/dashboards/simple-issues-list.controller.ts index 5a91041..705b326 100644 --- a/src/dashboards/simple-issues-list.controller.ts +++ b/src/dashboards/simple-issues-list.controller.ts @@ -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') diff --git a/src/dashboards/simple-kanban-board.controller.ts b/src/dashboards/simple-kanban-board.controller.ts index 0bed6b5..a504923 100644 --- a/src/dashboards/simple-kanban-board.controller.ts +++ b/src/dashboards/simple-kanban-board.controller.ts @@ -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('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') diff --git a/src/dashboards/widgets/issues-by-tags.widget.service.ts b/src/dashboards/widget-data-loader/issues-by-tags.widget-data-loader.service.ts similarity index 88% rename from src/dashboards/widgets/issues-by-tags.widget.service.ts rename to src/dashboards/widget-data-loader/issues-by-tags.widget-data-loader.service.ts index 13e2e9c..bca896f 100644 --- a/src/dashboards/widgets/issues-by-tags.widget.service.ts +++ b/src/dashboards/widget-data-loader/issues-by-tags.widget-data-loader.service.ts @@ -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 +export class IssuesByTagsWidgetDataLoaderService + implements WidgetDataLoaderInterface { - 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 { + async load(widgetParams: Params): Promise> { 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 {