Merge branch 'dev'
|
|
@ -9,5 +9,10 @@
|
|||
},
|
||||
"telegramBotToken": "",
|
||||
"personalMessageTemplate": "",
|
||||
"periodValidityNotification": 43200 // 12h
|
||||
"periodValidityNotification": 43200000, // 12h
|
||||
"tagManager": {
|
||||
"updateInterval": 15000,
|
||||
"updateItemsLimit": 3,
|
||||
"tagsCustomFieldName": "Tags"
|
||||
}
|
||||
}
|
||||
23
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
46
frontend/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
29198
frontend/package-lock.json
generated
Normal file
52
frontend/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.14",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"axios": "^1.4.0",
|
||||
"luxon": "^3.3.0",
|
||||
"mobx": "^6.9.0",
|
||||
"mobx-react-lite": "^3.4.3",
|
||||
"mobx-state-tree": "^5.1.8",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.11.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.3.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/public/images/event_emitter_eltex_loc-32px.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/images/event_emitter_eltex_loc-49px.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/public/images/Сharacter_01.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/public/images/Сharacter_02.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
43
frontend/public/index.html
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/logo192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/logo512.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
6
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter&display=swap');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
9
frontend/src/App.test.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
16
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import './App.css';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './router';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
13
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
19
frontend/src/index.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.listContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
background-color: rgb(225, 225, 225);
|
||||
}
|
||||
31
frontend/src/issues-list-board/issues-list-board.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import { IBoardStore } from './store';
|
||||
import Css from './issues-list-board.module.css';
|
||||
import * as IssuesListCardNs from './issues-list-card';
|
||||
|
||||
export type Props = {
|
||||
store: IBoardStore
|
||||
}
|
||||
|
||||
export const IssuesListBoard = observer((props: Props): JSX.Element => {
|
||||
const list: JSX.Element[] = props.store.data.map((issue) => {
|
||||
return (
|
||||
<IssuesListCardNs.IssuesListCard store={issue} key={issue.id}/>
|
||||
);
|
||||
});
|
||||
let title: JSX.Element;
|
||||
if (props.store.metainfo.url) {
|
||||
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
|
||||
} else {
|
||||
title = <>{props.store.metainfo.title}</>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 id={props.store.metainfo.title}>{title} <a href={`#${props.store.metainfo.title}`}>#</a></h1>
|
||||
<div className={Css.listContainer}>
|
||||
{list}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
24
frontend/src/issues-list-board/issues-list-boards-page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import * as IssuesListStoreNs from './store';
|
||||
import * as IssuesListBoardsNs from './issues-list-boards';
|
||||
|
||||
export const IssuesListBoardPage = (): JSX.Element => {
|
||||
const params = useParams();
|
||||
const name = params.name as string;
|
||||
const type = params.type as string;
|
||||
|
||||
// DEBUG: begin
|
||||
console.debug(`Issues list page: type=${type}; name=${name}`);
|
||||
useEffect(() => {
|
||||
console.debug(`Issues list page: type=${type}; name=${name}`);
|
||||
});
|
||||
// DEBUG: end
|
||||
|
||||
const store = IssuesListStoreNs.PageStore.create({loaded: false, type: type, name: name});
|
||||
IssuesListStoreNs.PageStoreLoadData(store);
|
||||
|
||||
return (
|
||||
<IssuesListBoardsNs.IssuesListBoards store={store}/>
|
||||
);
|
||||
};
|
||||
41
frontend/src/issues-list-board/issues-list-boards.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as IssuesListBoardStore from './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
|
||||
};
|
||||
|
||||
export const IssuesListBoards = observer((props: Props): JSX.Element => {
|
||||
const data = props.store.data;
|
||||
if (!props.store.loaded || !data) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const boardData = data[i];
|
||||
const key = boardData.metainfo.title;
|
||||
const board = <IssuesListBoardNs.IssuesListBoard store={boardData} key={key}/>
|
||||
list.push(board);
|
||||
}
|
||||
const topRightMenuStore = TopRightMenuNs.Store.create({visible: false});
|
||||
const onAllReadItemClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
SetIssuesReadingTimestamp(props.store.issueIds);
|
||||
IssuesListBoardStore.PageStoreLoadData(props.store);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
|
||||
<button onClick={onAllReadItemClick}>Прочитать всё</button>
|
||||
<ServiceActionsButtons.IssuesForceRefreshButton />
|
||||
<ServiceActionsButtons.GetIssuesQueueSizeButton />
|
||||
</TopRightMenuNs.TopRightMenu>
|
||||
{list}
|
||||
</>
|
||||
);
|
||||
});
|
||||
14
frontend/src/issues-list-board/issues-list-card.module.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.listItem {
|
||||
margin: 5px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.issueSubject {}
|
||||
|
||||
.issueStatus {}
|
||||
|
||||
.issueTime {}
|
||||
|
||||
.tagsContainer {}
|
||||
44
frontend/src/issues-list-board/issues-list-card.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import { IIssueStore } from './store';
|
||||
import Css from './issues-list-card.module.css';
|
||||
import * as TimePassedNs from '../misc-components/time-passed';
|
||||
import * as TagsNs from '../misc-components/tags';
|
||||
import * as IssueHrefNs from '../misc-components/issue-href';
|
||||
import * as IssueDetailsDialogNs from '../misc-components/issue-details-dialog';
|
||||
import * as UnreadedFlagNs from '../misc-components/unreaded-flag';
|
||||
|
||||
export type Props = {
|
||||
store: IIssueStore
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
return (
|
||||
<div className={Css.listItem} onClick={(e) => {e.stopPropagation(); detailsStore.show();}}>
|
||||
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore}/>
|
||||
<div>
|
||||
<UnreadedFlagNs.UnreadedFlag store={unreadedStore}/>
|
||||
<TimePassedNs.TimePassed params={{ fromIssue: { issue: props.store, keyName: 'timePassedClass' } }}/>
|
||||
<span className={Css.issueSubject}>
|
||||
<IssueHrefNs.IssueHref
|
||||
url={props.store.url?.url || ''}
|
||||
subject={props.store.subject}
|
||||
tracker={props.store.tracker?.name || ''}
|
||||
id={props.store.id}
|
||||
/>
|
||||
</span>
|
||||
<span className={Css.issueStatus}>| {props.store.status.name}</span>
|
||||
<span className={Css.issueTime}>| {props.store.total_spent_hours} / {props.store.total_estimated_hours}</span>
|
||||
</div>
|
||||
<div className={Css.tagsContainer}>
|
||||
<TagsNs.Tags params={{ tags: props.store.styledTags }}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
86
frontend/src/issues-list-board/store.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { Instance, types } from "mobx-state-tree";
|
||||
import { RedmineTypes } from "../redmine-types";
|
||||
import axios from "axios";
|
||||
|
||||
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
|
||||
|
||||
export interface IIssueStore extends Instance<typeof IssueStore> {}
|
||||
|
||||
export 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: ''
|
||||
}))
|
||||
});
|
||||
|
||||
export const BoardStore = types.model({
|
||||
data: types.array(IssueStore),
|
||||
metainfo: MetaInfoStore
|
||||
});
|
||||
|
||||
export interface IBoardStore extends Instance<typeof BoardStore> {}
|
||||
|
||||
export const PageStore = types.model({
|
||||
loaded: types.boolean,
|
||||
type: types.string,
|
||||
name: types.string,
|
||||
data: types.maybeNull(
|
||||
types.array(BoardStore)
|
||||
)
|
||||
}).actions((self) => {
|
||||
return {
|
||||
setData: (data: any) => {
|
||||
self.data = data;
|
||||
self.loaded = true;
|
||||
}
|
||||
};
|
||||
}).views((self) => {
|
||||
return {
|
||||
get issueIds(): number[] {
|
||||
if (!self.data) return [];
|
||||
const data = self.data;
|
||||
const res = [] as number[];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const itemData = data[i];
|
||||
for (let j = 0; j < itemData.data.length; j++) {
|
||||
const issue = itemData.data[j];
|
||||
if (res.indexOf(issue.id) < 0) {
|
||||
res.push(issue.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
|
||||
const url = `/simple-kanban-board/${store.type}/${store.name}/raw`;
|
||||
const resp = await axios.get(url);
|
||||
if (!(resp?.data)) return;
|
||||
|
||||
const data = [];
|
||||
for (let i = 0; i < resp.data.length; i++) {
|
||||
const item = resp.data[i] as {data: any[], metainfo: Record<string, any>};
|
||||
data.push({
|
||||
metainfo: item.metainfo,
|
||||
data: item.data ? item.data.map((group: { status: string, count: number, issues: any[] }) => {
|
||||
return group.issues
|
||||
}).flat() : []
|
||||
});
|
||||
}
|
||||
|
||||
/* DEBUG: begin */
|
||||
console.debug(`Issues list board store data: ${JSON.stringify(data)}`);
|
||||
/* DEBUG: end */
|
||||
|
||||
store.setData(data);
|
||||
}
|
||||
|
||||
export interface IPageStore extends Instance<typeof PageStore> {}
|
||||
11
frontend/src/kanban-board/column.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.kanbanColumn {
|
||||
width: 200px;
|
||||
background-color: rgb(225, 225, 225);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kanbanHeader {
|
||||
text-align: center;
|
||||
width: 200px;
|
||||
}
|
||||
27
frontend/src/kanban-board/column.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import ColumnCss from './column.module.css';
|
||||
import * as Stores from './store';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import * as KanbanCard from './kanban-card';
|
||||
|
||||
export type Props = {
|
||||
store: Stores.IColumnStore
|
||||
}
|
||||
|
||||
export const Column = observer((props: Props) => {
|
||||
const cards = props.store.cards.map((card) => {
|
||||
return (
|
||||
<KanbanCard.KanbanCard store={card} key={card.issue.id}></KanbanCard.KanbanCard>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className={ColumnCss.kanbanColumn}>
|
||||
<div className={ColumnCss.kanbanHeader}>
|
||||
{props.store.status} ({props.store.count})
|
||||
</div>
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Column;
|
||||
5
frontend/src/kanban-board/kanban-board.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.kanbanContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
}
|
||||
33
frontend/src/kanban-board/kanban-board.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import KanbanBoardCss from './kanban-board.module.css';
|
||||
import { IBoardStore } from './store';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import Column from './column';
|
||||
|
||||
export type Props = {
|
||||
store: IBoardStore
|
||||
};
|
||||
|
||||
export const KanbanBoard = observer((props: Props) => {
|
||||
let title: any;
|
||||
if (props.store.metainfo.url) {
|
||||
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
|
||||
} else {
|
||||
title = <>{props.store.metainfo.title}</>;
|
||||
}
|
||||
const columns = [];
|
||||
for (let i = 0; i < props.store.data.length; i++) {
|
||||
const column = props.store.data[i];
|
||||
columns.push(<Column store={column}/>)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 id={props.store.metainfo.title}>{title} <a href={`#${props.store.metainfo.title}`}>#</a></h1>
|
||||
<div className={KanbanBoardCss.kanbanContainer}>
|
||||
{columns}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default KanbanBoard;
|
||||
22
frontend/src/kanban-board/kanban-boards-page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import * as Stores from './store';
|
||||
import * as KBS from './kanban-boards';
|
||||
|
||||
export const KanbanBoardsPage = (): JSX.Element => {
|
||||
const params = useParams();
|
||||
const name = params.name as string;
|
||||
const type = params.type as string;
|
||||
|
||||
// DEBUG: begin
|
||||
console.debug(`KanbanBoardsPage: type=${type}; name=${name}`);
|
||||
useEffect(() => {
|
||||
console.debug(`KanbanBoardsPage: type=${type}; name=${name}`);
|
||||
});
|
||||
// DEBUG: end
|
||||
|
||||
const store = Stores.PageStore.create({loaded: false, type: type, name: name, data: null});
|
||||
Stores.PageStoreLoadData(store);
|
||||
|
||||
return <KBS.KanbanBoards store={store}/>;
|
||||
}
|
||||
54
frontend/src/kanban-board/kanban-boards.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import * as KB from './kanban-board';
|
||||
import { IPageStore, PageStoreLoadData } from './store';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import * as TopRightMenuNs from '../misc-components/top-right-menu';
|
||||
import { SetIssuesReadingTimestamp } from '../utils/unreaded-provider';
|
||||
import axios from 'axios';
|
||||
import * as ServiceActionsButtons from '../utils/service-actions-buttons';
|
||||
|
||||
export type Props = {
|
||||
store: IPageStore
|
||||
}
|
||||
|
||||
export const KanbanBoards = observer((props: Props) => {
|
||||
const data = props.store.data;
|
||||
if (!props.store.loaded || !data) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const boardData = data[i];
|
||||
const key = boardData.metainfo.title;
|
||||
const board = <KB.KanbanBoard store={boardData} key={key} />;
|
||||
list.push(board);
|
||||
}
|
||||
const topRightMenuStore = TopRightMenuNs.Store.create({visible: false});
|
||||
const onAllReadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
SetIssuesReadingTimestamp(props.store.issueIds);
|
||||
PageStoreLoadData(props.store);
|
||||
};
|
||||
let treeRefreshMenuItem: JSX.Element = <></>;
|
||||
if (props.store.canTreeRefresh) {
|
||||
const onTreeRefreshClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
axios.get(`/simple-kanban-board/tree/${props.store.name}/refresh`);
|
||||
}
|
||||
treeRefreshMenuItem = <button onClick={onTreeRefreshClick}>Force tree refresh</button>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
|
||||
<button onClick={onAllReadClick}>Всё прочитано</button>
|
||||
{treeRefreshMenuItem}
|
||||
<ServiceActionsButtons.IssuesForceRefreshButton/>
|
||||
<ServiceActionsButtons.GetIssuesQueueSizeButton/>
|
||||
</TopRightMenuNs.TopRightMenu>
|
||||
{list}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default KanbanBoards;
|
||||
56
frontend/src/kanban-board/kanban-card.module.css
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
.kanbanCard {
|
||||
background-color: rgb(196, 196, 196);
|
||||
border-width: 1px;
|
||||
border-color: rgba(255, 255, 255, 0.2) rgba(96, 96, 96, 0.2) rgba(96, 96, 96, 0.2) rgba(255, 255, 255, 0.2);
|
||||
/*border-color: black;*/
|
||||
border-style: solid;
|
||||
margin: 2px;
|
||||
padding: 3px;
|
||||
width: 190px;
|
||||
/*display: flex;*/
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 3px;
|
||||
/* z-index: 100; */
|
||||
}
|
||||
|
||||
.kanbanCard div {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.kanbanCard .kanbanCardTitle {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timepassedDot {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
background-color: #bbb;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.timepassedDot.hot {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
.timepassedDot.warm {
|
||||
background-color: orange;
|
||||
}
|
||||
|
||||
.timepassedDot.comfort {
|
||||
background-color: rgba(255, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.timepassedDot.breezy {
|
||||
background-color: rgba(0, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.timepassedDot.cold {
|
||||
background-color: rgba(0, 0, 255, 0.1);
|
||||
}
|
||||
|
||||
.kanbanCardTag {
|
||||
font-size: 8pt;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
78
frontend/src/kanban-board/kanban-card.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import KanbanCardCss from './kanban-card.module.css';
|
||||
import React from 'react';
|
||||
import { ICardStore } from './store';
|
||||
import { getStyleObjectFromString } from '../utils/style';
|
||||
import * as TimePassedNs from '../misc-components/time-passed';
|
||||
import * as TagsNs from '../misc-components/tags';
|
||||
import * as IssueDetailsDialogNs from '../misc-components/issue-details-dialog';
|
||||
import * as UnreadedFlagNs from '../misc-components/unreaded-flag';
|
||||
|
||||
export type Props = {
|
||||
store: ICardStore
|
||||
};
|
||||
|
||||
export type TagProps = {
|
||||
style?: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export const KanbanCardTag = (props: TagProps): JSX.Element => {
|
||||
const inlineStyle = getStyleObjectFromString(props.style || '');
|
||||
return (
|
||||
<span className={KanbanCardCss.kanbanCardTag} style={inlineStyle}>
|
||||
{props.tag}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Какие дальше требования к карточкам?
|
||||
*
|
||||
* 1. Отобразить как было в статичной доске
|
||||
* 2. Переделать отображение с учётом store.params
|
||||
*/
|
||||
|
||||
export const KanbanCard = observer((props: Props) => {
|
||||
let tagsSection = <></>;
|
||||
const tagsParams = props.store.params.fields.find((field) => {
|
||||
return field.component === 'tags';
|
||||
});
|
||||
console.debug('Tag params:', tagsParams); // DEBUG
|
||||
console.debug('Issue:', props.store.issue); // DEBUG
|
||||
if (tagsParams && props.store.issue[tagsParams.path]) {
|
||||
const tags = props.store.issue[tagsParams.path] as TagProps[];
|
||||
console.debug(`Tags:`, tags); // DEBUG
|
||||
tagsSection = <TagsNs.Tags params={{tags: tags}}/>
|
||||
}
|
||||
const timePassedParams: TimePassedNs.Params = {
|
||||
fromIssue: {
|
||||
issue: props.store.issue,
|
||||
keyName: 'timePassedClass'
|
||||
}
|
||||
}
|
||||
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store.issue);
|
||||
const detailsStore = IssueDetailsDialogNs.Store.create({
|
||||
issue: props.store.issue,
|
||||
visible: false,
|
||||
unreadedFlagStore: unreadedStore
|
||||
});
|
||||
return (
|
||||
<div className={KanbanCardCss.kanbanCard} onClick={(e) => {e.stopPropagation(); detailsStore.show();}}>
|
||||
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
|
||||
<div className={KanbanCardCss.kanbanCardTitle}>
|
||||
<UnreadedFlagNs.UnreadedFlag store={unreadedStore}/>
|
||||
<TimePassedNs.TimePassed params={timePassedParams}/>
|
||||
<a href={props.store.issue.url.url}>{props.store.issue.tracker.name} #{props.store.issue.id} - {props.store.issue.subject}</a>
|
||||
</div>
|
||||
<div>Исп.: {props.store.issue.current_user.name}</div>
|
||||
<div>Прио.: {props.store.issue.priority.name}</div>
|
||||
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div>
|
||||
<div>Прогресс: {props.store.issue.done_ratio}</div>
|
||||
<div>Трудозатраты: {props.store.issue.total_spent_hours} / {props.store.issue.total_estimated_hours}</div>
|
||||
{tagsSection}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default KanbanCard;
|
||||
127
frontend/src/kanban-board/store.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Instance, types } from 'mobx-state-tree';
|
||||
import { RedmineTypes } from '../redmine-types';
|
||||
import axios from 'axios';
|
||||
|
||||
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>()
|
||||
|
||||
export interface IIssueStore extends Instance<typeof IssueStore> {}
|
||||
|
||||
export const ColumnStore = types.model({
|
||||
status: '',
|
||||
count: 0,
|
||||
issues: types.array(IssueStore)
|
||||
}).views((self) => {
|
||||
return {
|
||||
get cards(): ICardStore[] {
|
||||
return self.issues.map(issue => {
|
||||
return CardStore.create({
|
||||
issue: issue
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export interface IColumnStore extends Instance<typeof ColumnStore> {}
|
||||
|
||||
export const MetaInfoStore = types.model({
|
||||
title: '',
|
||||
url: types.maybe(types.string),
|
||||
rootIssue: types.maybe(types.model({
|
||||
id: 0,
|
||||
tracker: types.model({
|
||||
id: 0,
|
||||
name: ''
|
||||
}),
|
||||
subject: ''
|
||||
}))
|
||||
});
|
||||
|
||||
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {}
|
||||
|
||||
export const BoardStore = types.model({
|
||||
data: types.array(ColumnStore),
|
||||
metainfo: MetaInfoStore
|
||||
});
|
||||
|
||||
export interface IBoardStore extends Instance<typeof BoardStore> {}
|
||||
|
||||
export const PageStore = types.model({
|
||||
loaded: false,
|
||||
type: '',
|
||||
name: '',
|
||||
data: types.maybeNull(
|
||||
types.array(BoardStore)
|
||||
)
|
||||
}).actions(self => {
|
||||
return {
|
||||
setData: (data: any) => {
|
||||
self.data = data;
|
||||
self.loaded = true;
|
||||
}
|
||||
};
|
||||
}).views((self) => {
|
||||
return {
|
||||
get issueIds(): number[] {
|
||||
if (!self.data) return [];
|
||||
const res = [] as number[];
|
||||
for (let i = 0; i < self.data.length; i++) {
|
||||
const iData = self.data[i];
|
||||
for (let j = 0; j < iData.data.length; j++) {
|
||||
const jData = iData.data[j];
|
||||
for (let k = 0; k < jData.issues.length; k++) {
|
||||
const issue = jData.issues[k];
|
||||
if (res.indexOf(issue.id) < 0) {
|
||||
res.push(issue.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
get canTreeRefresh(): boolean {
|
||||
return (self.type === 'tree');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
|
||||
const url = `/simple-kanban-board/${store.type}/${store.name}/raw`;
|
||||
const resp = await axios.get(url);
|
||||
if (!(resp?.data)) return;
|
||||
store.setData(resp.data);
|
||||
}
|
||||
|
||||
export interface IPageStore extends Instance<typeof PageStore> { }
|
||||
|
||||
export type CardField = {
|
||||
component: string;
|
||||
} & Record<string, any>;
|
||||
|
||||
export const CardParamsStore = types.optional(
|
||||
types.model({
|
||||
fields: types.array(
|
||||
types.frozen<CardField>()
|
||||
),
|
||||
autoCollapse: types.boolean
|
||||
}),
|
||||
{
|
||||
fields: [
|
||||
{ component: 'text', label: 'Исп.', path: 'current_user.name' },
|
||||
{ component: 'text', label: 'Прио.', path: 'priority.name' },
|
||||
{ component: 'text', label: 'Версия', path: 'fixed_version.name' },
|
||||
{ component: 'text', label: 'Прогресс', path: 'done_ratio' },
|
||||
{ component: 'labor_costs' },
|
||||
{ component: 'tags', label: 'Tags', path: 'styledTags' }
|
||||
],
|
||||
autoCollapse: false,
|
||||
}
|
||||
);
|
||||
|
||||
export const CardStore = types.model({
|
||||
issue: IssueStore,
|
||||
params: CardParamsStore
|
||||
});
|
||||
|
||||
export interface ICardStore extends Instance<typeof CardStore> {}
|
||||
|
||||
1
frontend/src/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
43
frontend/src/misc-components/issue-details-dialog.module.css
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
.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%;
|
||||
}
|
||||
|
||||
.modalContent pre {
|
||||
font-size: 10pt;
|
||||
/* word-wrap: normal; */
|
||||
white-space: pre-wrap;
|
||||
/* Since CSS 2.1 */
|
||||
white-space: -moz-pre-wrap;
|
||||
/* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap;
|
||||
/* Opera 4-6 */
|
||||
white-space: -o-pre-wrap;
|
||||
/* Opera 7 */
|
||||
word-wrap: break-word;
|
||||
/* Internet Explorer 5.5+ */
|
||||
}
|
||||
|
||||
.dateField {
|
||||
font-size: 7pt;
|
||||
font-weight: normal;
|
||||
}
|
||||
113
frontend/src/misc-components/issue-details-dialog.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import React from 'react';
|
||||
import { RedmineTypes } from '../redmine-types';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Instance, types } from 'mobx-state-tree';
|
||||
import * as IssueHrefNs from '../misc-components/issue-href';
|
||||
import Css from './issue-details-dialog.module.css';
|
||||
import * as UnreadedFlagNs from '../misc-components/unreaded-flag';
|
||||
import { SetIssueReadingTimestamp } from '../utils/unreaded-provider';
|
||||
import axios from 'axios';
|
||||
import * as Luxon from 'luxon';
|
||||
|
||||
export const Store = types.model({
|
||||
visible: types.boolean,
|
||||
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
|
||||
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store)
|
||||
}).actions((self) => {
|
||||
return {
|
||||
hide: () => {
|
||||
console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG
|
||||
self.visible = false;
|
||||
},
|
||||
show: () => {
|
||||
console.debug(`Issue details dialog show: issue_id=${self.issue.id}`); // DEBUG
|
||||
self.visible = true;
|
||||
if (self.unreadedFlagStore) {
|
||||
self.unreadedFlagStore.read();
|
||||
} else {
|
||||
SetIssueReadingTimestamp(self.issue.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
}).views((self) => {
|
||||
return {
|
||||
get displayStyle(): React.CSSProperties {
|
||||
return {display: self.visible ? 'block' : 'none'};
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof Store>
|
||||
};
|
||||
|
||||
export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
|
||||
const onUpdateClick = (e: React.MouseEvent) => {
|
||||
const url = `/redmine-event-emitter/append-issues`;
|
||||
axios.post(url, [props.store.issue.id]);
|
||||
};
|
||||
const onCloseClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
props.store.hide();
|
||||
};
|
||||
return (
|
||||
<div className={Css.modal} style={props.store.displayStyle} onClick={onCloseClick}>
|
||||
<div className={Css.modalContent}>
|
||||
<h1>
|
||||
<button onClick={onCloseClick}>close</button>
|
||||
<button onClick={onUpdateClick}>force update</button>
|
||||
<IssueHrefNs.IssueHref
|
||||
id={props.store.issue?.id || -1}
|
||||
subject={props.store.issue?.subject || ''}
|
||||
tracker={props.store.issue?.tracker?.name || ''}
|
||||
url={props.store.issue?.url?.url || ''}
|
||||
/>
|
||||
</h1>
|
||||
<hr/>
|
||||
<div>
|
||||
<h2>Описание:</h2>
|
||||
<pre>
|
||||
{props.store.issue.description}
|
||||
</pre>
|
||||
</div>
|
||||
<hr/>
|
||||
<div>
|
||||
<h2>Комментарии:</h2>
|
||||
<Comments details={props.store.issue.journals || []} issue={props.store.issue}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const Comments = (props: {details?: RedmineTypes.Journal[], issue: RedmineTypes.ExtendedIssue}): JSX.Element => {
|
||||
const comments = props.details?.filter((detail) => {
|
||||
return Boolean(detail.notes);
|
||||
});
|
||||
if (!comments) {
|
||||
return <>No comments</>
|
||||
}
|
||||
const list = comments.map((detail) => {
|
||||
const key = `issueid_${props.issue.id}_commentid_${detail.id}`;
|
||||
return <Comment data={detail} key={key}/>
|
||||
});
|
||||
return (
|
||||
<>{list}</>
|
||||
);
|
||||
}
|
||||
|
||||
export const Comment = (props: {data: RedmineTypes.Journal}): JSX.Element => {
|
||||
const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat("dd.MM.yyyy HH:mm");
|
||||
return (
|
||||
<>
|
||||
<h3><span className={Css.dateField}>{date}</span> {props.data.user.name}:</h3>
|
||||
<div>
|
||||
<pre>
|
||||
{props.data.notes || '-'}
|
||||
</pre>
|
||||
</div>
|
||||
<hr/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
frontend/src/misc-components/issue-href.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
export type Props = {
|
||||
url: string;
|
||||
id: number;
|
||||
subject: string;
|
||||
tracker: string;
|
||||
};
|
||||
|
||||
export const IssueHref = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<a href={props.url}>{props.tracker} #{props.id} - {props.subject}</a>
|
||||
);
|
||||
};
|
||||
5
frontend/src/misc-components/tag.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.tag {
|
||||
font-size: 8pt;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
20
frontend/src/misc-components/tag.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import { getStyleObjectFromString } from '../utils/style';
|
||||
import Css from './tag.module.css';
|
||||
|
||||
export type Props = {
|
||||
style?: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export const Tag = (props: Props): JSX.Element => {
|
||||
const inlineStyle = getStyleObjectFromString(props.style || '');
|
||||
return (
|
||||
<>
|
||||
<span> </span>
|
||||
<span className={Css.tag} style={inlineStyle}>
|
||||
{props.tag}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
frontend/src/misc-components/tags.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import * as TagNs from './tag';
|
||||
|
||||
export type Params = {
|
||||
label?: string;
|
||||
tags: TagNs.Props[];
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
params: Params
|
||||
};
|
||||
|
||||
export const Tags = (props: Props): JSX.Element => {
|
||||
if (!props.params.tags) {
|
||||
return (<></>);
|
||||
}
|
||||
const label = props.params.label || 'Tags';
|
||||
const tags = props.params.tags.map((tag) => {
|
||||
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag}/>;
|
||||
}) || [];
|
||||
return (
|
||||
<div>
|
||||
{label}: {tags}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend/src/misc-components/time-passed.module.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
.timepassedDot {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
background-color: #bbb;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.timepassedDot.hot {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
.timepassedDot.warm {
|
||||
background-color: orange;
|
||||
}
|
||||
|
||||
.timepassedDot.comfort {
|
||||
background-color: rgba(255, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.timepassedDot.breezy {
|
||||
background-color: rgba(0, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.timepassedDot.cold {
|
||||
background-color: rgba(0, 0, 255, 0.1);
|
||||
}
|
||||
48
frontend/src/misc-components/time-passed.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React from 'react';
|
||||
import Css from './time-passed.module.css';
|
||||
import { RedmineTypes } from '../redmine-types';
|
||||
|
||||
export type Params = {
|
||||
fromIssue?: {
|
||||
issue: RedmineTypes.ExtendedIssue,
|
||||
keyName: string,
|
||||
},
|
||||
fromValue?: string
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
params: Params
|
||||
};
|
||||
|
||||
export const TimePassed = (props: Props): JSX.Element => {
|
||||
if (!props.params.fromIssue && !props.params.fromValue) {
|
||||
return <></>;
|
||||
}
|
||||
let timePassedClassName = ''; // TODO
|
||||
if (props.params.fromIssue) {
|
||||
const { issue, keyName } = props.params.fromIssue;
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(issue[keyName])}`;
|
||||
} else if (props.params.fromValue) {
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(props.params.fromValue)}`;
|
||||
}
|
||||
return (
|
||||
<span className={timePassedClassName}></span>
|
||||
);
|
||||
};
|
||||
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
16
frontend/src/misc-components/top-right-menu.module.css
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.menuButton {
|
||||
position: fixed;
|
||||
width: 45px;
|
||||
height: 25px;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #000000;
|
||||
position: fixed;
|
||||
top: 25px;
|
||||
left: 0px;
|
||||
display: none;
|
||||
}
|
||||
55
frontend/src/misc-components/top-right-menu.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
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 type Props = {
|
||||
store: Instance<typeof Store>;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const TopRightMenu = observer((props: Props): JSX.Element => {
|
||||
const menuItems = [];
|
||||
if (props.children.length > 1) {
|
||||
for (let key = 0; key < props.children.length; key++) {
|
||||
const item = props.children[key];
|
||||
menuItems.push(<li key={key}>{item}</li>);
|
||||
}
|
||||
} else if (props.children) {
|
||||
menuItems.push(<li key={0}>{props.children}</li>)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button className={Css.menuButton} onClick={(e) => {e.stopPropagation(); props.store.toggle();}}>Menu</button>
|
||||
<div className={Css.menu} style={props.store.style}>
|
||||
<ul>
|
||||
{menuItems}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
16
frontend/src/misc-components/unreaded-flag.module.css
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.circle {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
background-color: #707070;
|
||||
border: 2px solid #707070;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.circle.unreaded {
|
||||
border: 2px solid #0000FF;
|
||||
}
|
||||
|
||||
.circle.forMe {
|
||||
background-color: #FF0000;
|
||||
}
|
||||
64
frontend/src/misc-components/unreaded-flag.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
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';
|
||||
|
||||
export const Store = types.model({
|
||||
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
|
||||
readingTimestamp: types.number
|
||||
}).actions((self) => {
|
||||
return {
|
||||
read: () => {
|
||||
self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id);
|
||||
}
|
||||
};
|
||||
}).views((self) => {
|
||||
return {
|
||||
getUpdatedTimestap(): number {
|
||||
if (self.issue.journals) {
|
||||
let lastComment: RedmineTypes.Journal | undefined;
|
||||
for (let i = self.issue.journals.length - 1; i >= 0; i--) {
|
||||
const journal = self.issue.journals[i];
|
||||
if (journal.notes) {
|
||||
lastComment = journal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastComment) {
|
||||
return (new Date(lastComment.created_on)).getTime();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
getClassName(): string {
|
||||
let className = Css.circle;
|
||||
const updatedTimestamp = this.getUpdatedTimestap();
|
||||
if (self.readingTimestamp < updatedTimestamp) {
|
||||
className += ` ${Css.unreaded}`;
|
||||
}
|
||||
console.debug(`Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`); // DEBUG
|
||||
return className;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export function CreateStoreFromLocalStorage(issue: RedmineTypes.ExtendedIssue) {
|
||||
const timestamp = GetIssueReadingTimestamp(issue.id);
|
||||
return Store.create({
|
||||
issue: issue,
|
||||
readingTimestamp: timestamp
|
||||
});
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof Store>
|
||||
}
|
||||
|
||||
export const UnreadedFlag = observer((props: Props): JSX.Element => {
|
||||
const className = props.store.getClassName();
|
||||
return (
|
||||
<span className={className} onClick={(e) => {e.stopPropagation(); props.store.read();}}></span>
|
||||
);
|
||||
})
|
||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
||||
140
frontend/src/redmine-types.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
|
||||
export module RedmineTypes {
|
||||
export type IdAndName = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CustomField = {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type JournalDetail = {
|
||||
property: string;
|
||||
name: string;
|
||||
old_value?: string;
|
||||
new_value?: string;
|
||||
};
|
||||
|
||||
export type Journal = {
|
||||
id: number;
|
||||
user: IdAndName;
|
||||
notes?: string;
|
||||
created_on: string;
|
||||
details?: JournalDetail[];
|
||||
};
|
||||
|
||||
export type ChildIssue = {
|
||||
id: number;
|
||||
tracker: IdAndName;
|
||||
subject: string;
|
||||
children?: Children;
|
||||
};
|
||||
|
||||
export type Children = ChildIssue[];
|
||||
|
||||
export type Issue = {
|
||||
id: number;
|
||||
project: IdAndName;
|
||||
tracker: IdAndName;
|
||||
status: IdAndName;
|
||||
priority: IdAndName;
|
||||
author: IdAndName;
|
||||
assigned_to?: IdAndName;
|
||||
category: IdAndName;
|
||||
fixed_version?: IdAndName;
|
||||
subject: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
done_ratio: number;
|
||||
spent_hours: number;
|
||||
total_spent_hours: number;
|
||||
custom_fields: CustomField[];
|
||||
created_on: string;
|
||||
updated_on?: string;
|
||||
closed_on?: string;
|
||||
relations?: Record<string, any>[];
|
||||
journals?: Journal[];
|
||||
children?: Children;
|
||||
parent?: { id: number };
|
||||
};
|
||||
|
||||
export type ExtendedIssue = Issue & Record<string, any>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
|
||||
export module Unknown {
|
||||
export const num = -1;
|
||||
export const str = '';
|
||||
export const idAndName: IdAndName = {
|
||||
id: -1,
|
||||
name: str,
|
||||
};
|
||||
export const unknownName = 'Unknown';
|
||||
export const subject = 'Unknown';
|
||||
export const date = '1970-01-01T00:00:00Z';
|
||||
export const issue: Issue = {
|
||||
id: num,
|
||||
project: idAndName,
|
||||
tracker: idAndName,
|
||||
status: idAndName,
|
||||
priority: idAndName,
|
||||
author: idAndName,
|
||||
category: idAndName,
|
||||
fixed_version: idAndName,
|
||||
subject: subject,
|
||||
description: str,
|
||||
start_date: date,
|
||||
done_ratio: num,
|
||||
spent_hours: num,
|
||||
total_spent_hours: num,
|
||||
custom_fields: [],
|
||||
created_on: date,
|
||||
};
|
||||
|
||||
export const user: User = {
|
||||
id: num,
|
||||
login: str,
|
||||
firstname: unknownName,
|
||||
lastname: unknownName,
|
||||
mail: str,
|
||||
};
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
login: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
mail: string;
|
||||
};
|
||||
|
||||
export type PublicUser = {
|
||||
id: number;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
login: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function CreatePublicUserFromUser(obj: User): PublicUser {
|
||||
return {
|
||||
id: obj.id,
|
||||
login: obj.login,
|
||||
firstname: obj.firstname,
|
||||
lastname: obj.lastname,
|
||||
name: `${obj.firstname} ${obj.lastname}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateUser(obj: User): User {
|
||||
return {
|
||||
id: obj.id,
|
||||
login: obj.login,
|
||||
firstname: obj.firstname,
|
||||
lastname: obj.lastname,
|
||||
mail: obj.mail,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
frontend/src/reportWebVitals.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
25
frontend/src/router.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: (<StartPage/>),
|
||||
},
|
||||
{
|
||||
path: "/kanban-board/:type/:name",
|
||||
element: (<KanbanBoardsPage/>)
|
||||
},
|
||||
{
|
||||
path: "/issues-list-board/:type/:name",
|
||||
element: (<IssuesListBoardPage/>)
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: (<UnknownPage/>)
|
||||
}
|
||||
]);
|
||||
5
frontend/src/setupTests.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
83
frontend/src/start-page/basement.module.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
.basement {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #001826;
|
||||
height: 330px;
|
||||
border: 10px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.basementGrid {
|
||||
position: relative;
|
||||
margin-left: 135px;
|
||||
margin-right: 135px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bottomContacts {
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
.bottomContacts a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.discuss {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.discussButton {
|
||||
background-color: #D9D9D9;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: 1px 30px;
|
||||
line-height: normal;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.discussText {
|
||||
font-size: 40px;
|
||||
color: #001826;
|
||||
}
|
||||
|
||||
.discussText:hover {
|
||||
color: #268ccc;
|
||||
}
|
||||
|
||||
.textBoxTextOrange {
|
||||
--tw-text-opacity: 1;
|
||||
color: #F5B14E;
|
||||
}
|
||||
|
||||
.eventEmitterEltexLoc {
|
||||
vertical-align: middle;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
transition: width 0.5s, height 0.5s;
|
||||
}
|
||||
|
||||
.eventEmitterEltexLocIcon {
|
||||
vertical-align: middle;
|
||||
height: 52px;
|
||||
padding: 0em 15px 0px 0px;
|
||||
}
|
||||
|
||||
.character02 {
|
||||
padding-left: 20px;
|
||||
}
|
||||
41
frontend/src/start-page/basement.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import BasementCss from './basement.module.css';
|
||||
import { router } from '../router';
|
||||
|
||||
export type Props = {
|
||||
contactUrl: string;
|
||||
iconUrl: string;
|
||||
characterUrl: string;
|
||||
};
|
||||
|
||||
export const Basement = (props: Props): JSX.Element => {
|
||||
console.debug('routes:', router.routes); // DEBUG
|
||||
|
||||
return (
|
||||
<div className={BasementCss.basement}>
|
||||
<div className={BasementCss.basementGrid}>
|
||||
<div className={BasementCss.bottomContacts}>
|
||||
<a href="/">
|
||||
<img src={props.iconUrl} alt="event_emitter_eltex_loc" className={BasementCss.eventEmitterEltexLoc} />
|
||||
<span>redmine-issue-event-emitter</span>
|
||||
</a>
|
||||
<p><a href={props.contactUrl}> Проект
|
||||
<span className={BasementCss.textBoxTextOrange}> Павел Гнедов</span>
|
||||
</a></p>
|
||||
</div>
|
||||
|
||||
<div className={BasementCss.discuss}>
|
||||
<div className={BasementCss.discussButton}>
|
||||
<a href="https://t.me/pavelgnedov">
|
||||
<p className={BasementCss.discussText}> ОБСУДИТЬ </p>
|
||||
</a>
|
||||
</div>
|
||||
<img src={props.characterUrl} width="100" alt="Сharacter" className={BasementCss.character02} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Basement;
|
||||
17
frontend/src/start-page/content-block.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
export type Props = {
|
||||
title: string;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const ContentBlock = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<h2>{props.title}</h2>
|
||||
{props.children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentBlock;
|
||||
9
frontend/src/start-page/content.module.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.content {
|
||||
margin-left: 135px;
|
||||
padding: 956px 0px 40px 0px;
|
||||
max-width: 1170px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 32px;
|
||||
}
|
||||
16
frontend/src/start-page/content.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import ContentCss from './content.module.css';
|
||||
|
||||
export type Props = {
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const Content = (props: Props) => {
|
||||
return (
|
||||
<div className={ContentCss.content}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Content;
|
||||
70
frontend/src/start-page/cover.module.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
.cover {
|
||||
box-sizing: border-box;
|
||||
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 900px;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
|
||||
background: #001826;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cover h1 {
|
||||
left: 0%;
|
||||
right: 0%;
|
||||
top: 18.22%;
|
||||
bottom: 24.7%;
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 94px;
|
||||
line-height: 94px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cover h3 {
|
||||
color: #F5B14E;
|
||||
font-family: 'Source Code Pro';
|
||||
letter-spacing: 0.12em;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.cover h4 {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-size: 40px;
|
||||
line-height: 60px;
|
||||
letter-spacing: -0.01em;
|
||||
text-transform: uppercase;
|
||||
font-feature-settings: 'pnum' on, 'lnum' on;
|
||||
}
|
||||
|
||||
.cover h4:hover {
|
||||
text-decoration: underline;
|
||||
color: #F5B14E;
|
||||
}
|
||||
|
||||
.character {
|
||||
position: absolute;
|
||||
width: 375px;
|
||||
height: 672px;
|
||||
left: 130px;
|
||||
top: 181px;
|
||||
}
|
||||
|
||||
.info {
|
||||
font-family: 'Source Code Pro';
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
position: absolute;
|
||||
max-width: 800px;
|
||||
max-height: 494px;
|
||||
left: 635px;
|
||||
top: 251px;
|
||||
}
|
||||
23
frontend/src/start-page/cover.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import CoverCss from './cover.module.css';
|
||||
|
||||
export type CoverProps = {
|
||||
telegramBotUrl: string;
|
||||
};
|
||||
|
||||
export const Cover = (props: CoverProps) => {
|
||||
return (
|
||||
<div className={CoverCss.cover}>
|
||||
<img src="/images/Сharacter_01.png" alt="Сharacter" className={CoverCss.character} />
|
||||
<div className={CoverCss.info}>
|
||||
<h3>Redmine Issue Event Emitter</h3>
|
||||
<h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1>
|
||||
<h4>
|
||||
<a href={props.telegramBotUrl}> ссылка на телеграмм бота</a>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cover;
|
||||
BIN
frontend/src/start-page/event_emitter_eltex_loc-32px.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
32
frontend/src/start-page/notification-block.module.css
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.message {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event_emitter_eltex_loc_icon {
|
||||
vertical-align: middle;
|
||||
height: 52px;
|
||||
padding: 0em 15px 0px 0px;
|
||||
}
|
||||
|
||||
.text_box {
|
||||
width: 760px;
|
||||
background-color: white;
|
||||
border-radius: 28px;
|
||||
box-shadow: 4px 4px 14px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.text_box_text {
|
||||
padding-top: 0px;
|
||||
padding-left: 40px;
|
||||
padding-right: 40px;
|
||||
padding-bottom: 0px;
|
||||
font-size: 16pt;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.text_box_text_blue {
|
||||
color: rgb(38, 140, 204);
|
||||
}
|
||||
27
frontend/src/start-page/notification-block.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import NotificationBlockCss from './notification-block.module.css';
|
||||
|
||||
export type Props = {
|
||||
avatarUrl: string;
|
||||
taskTitle?: string;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const NotificationBlock = (props: Props) => {
|
||||
const taskTitle = props?.taskTitle
|
||||
? (<span className={NotificationBlockCss.text_box_text_blue}>{props.taskTitle} </span>)
|
||||
: (<></>);
|
||||
return (
|
||||
<div className={NotificationBlockCss.message}>
|
||||
<img src={props.avatarUrl} alt="event_emitter_eltex_loc" className={NotificationBlockCss.event_emitter_eltex_loc_icon} />
|
||||
<div className={NotificationBlockCss.text_box}>
|
||||
<p className={NotificationBlockCss.text_box_text}>
|
||||
{taskTitle}
|
||||
{props.children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationBlock;
|
||||
25
frontend/src/start-page/start-page.module.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.startPage {
|
||||
margin: 0;
|
||||
background-color: #D9D9D9;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.startPage a {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.startPage a:hover {
|
||||
color: #F5B14E;
|
||||
}
|
||||
|
||||
.startPage a:active {
|
||||
color: #F5B14E;
|
||||
}
|
||||
103
frontend/src/start-page/start-page.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react';
|
||||
import Basement from './basement';
|
||||
import Content from './content';
|
||||
import ContentBlock from './content-block';
|
||||
import Cover from './cover';
|
||||
import NotificationBlock from './notification-block';
|
||||
import NotificationBlockCss from './notification-block.module.css';
|
||||
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'
|
||||
};
|
||||
|
||||
export const StartPage = () => {
|
||||
return (
|
||||
<div className={StartPageCss.startPage}>
|
||||
<TopBar contact={StartPageData.contact} />
|
||||
<Cover telegramBotUrl={StartPageData.bot} />
|
||||
<Content>
|
||||
<ContentBlock title='Возможности'>
|
||||
<ul>
|
||||
<li>Уведомления в реальном времени о событиях из задач - изменения статусов, упоминания комментариев</li>
|
||||
<li>Генерация и управления отчётами о задачах</li>
|
||||
<li>Под капотом приложение фреймворк</li>
|
||||
</ul>
|
||||
</ContentBlock>
|
||||
<ContentBlock title='Функции telegram бота'>
|
||||
<ul>
|
||||
<li>Последний отчёт для дейли проект ECCM</li>
|
||||
<li>Дополнительные функции для разработчиков
|
||||
eccm:/current_issues_eccm - список текущих задач по статусам - выбираютсятолько задачи из актуальных версий в статусах, где нужна какая-то реакцияили возможна работа прямо сейчас</li>
|
||||
<li>Скриншоты уведомления от бота:
|
||||
Примеры уведомлений о новых задачах и об изменениях статусов:</li>
|
||||
</ul>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Feature #245005'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Реализовать поддержку нового протокола: <br/><br/>
|
||||
Стив Джобс изменил статус задачи с Feedback на Closed
|
||||
</NotificationBlock>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Feature #241201'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Добавить поддержку новых моделей: <br/><br/>
|
||||
|
||||
Билл Гейтс создал новую задачу и назначил её на вас
|
||||
</NotificationBlock>
|
||||
|
||||
<p>Простые уведомления о движении задач - и больше ничего лишнего.
|
||||
Пример уведомления по личному упоминанию в задаче:
|
||||
</p>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Question #230033'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Сергей Брин:<br/><br/>
|
||||
|
||||
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по описанию к этой задаче.
|
||||
</NotificationBlock>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Bug #191122'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Исправление уязвимости<br/><br/>
|
||||
|
||||
Линус Торвальдс завершил разработку по задаче и передал вам на ревью<br/><br/>
|
||||
|
||||
Кажется получилось поправить проблемку. Глянь мой MR.
|
||||
</NotificationBlock>
|
||||
|
||||
<p>Можно задавать коллегам вопросы прямо из комментария задачи, неотрываясь от её содержимого. Уведомление доставится в считанные минуты с ссылкой на задачу и информацией от кого это уведомление.</p>
|
||||
<p>Пример запроса моих текущих задач с помощью команды
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>/current_issues_eccm</span>
|
||||
</p>
|
||||
|
||||
<NotificationBlock
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Бьёрн Страуструп:<br/><br/>
|
||||
|
||||
Re-opened:<br/><br/>
|
||||
<span className={NotificationBlockCss.text_box_text_blue}> - Feature #223301: </span>
|
||||
Дополнить stdlib новыми функциями (прио - P4, версия - C++23)<br/><br/>
|
||||
In Progress:<br/><br/>
|
||||
<span className={NotificationBlockCss.text_box_text_blue}> - Question #223411:</span>
|
||||
Выпуск релиза C++23 (прио - P4, версия - C++23)
|
||||
</NotificationBlock>
|
||||
</ContentBlock>
|
||||
</Content>
|
||||
<Basement contactUrl={StartPageData.contact} characterUrl='/images/Сharacter_02.png' iconUrl='/images/event_emitter_eltex_loc-32px.png'/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartPage;
|
||||
78
frontend/src/start-page/top-bar.module.css
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/* .top .container_title .logo .event_emitter_eltex_loc */
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100500;
|
||||
height: 80px;
|
||||
background-color: #001826;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.containerTitle {
|
||||
position: relative;
|
||||
margin-left: 135px;
|
||||
margin-right: 135px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.containerTitle {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin-left: 100px;
|
||||
margin-right: 100px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.eventEmitterEltexLoc {
|
||||
vertical-align: middle;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
transition: width 0.5s, height 0.5s;
|
||||
}
|
||||
|
||||
.eventEmitterEltexLoc:hover {
|
||||
/* height: 38px; */
|
||||
/* transform: scale(1.5); */
|
||||
}
|
||||
|
||||
div.logo {
|
||||
height: 38px;
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.logo:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
a.logo {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a.logo:active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a.logo span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
31
frontend/src/start-page/top-bar.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
import TopBarCss from './top-bar.module.css';
|
||||
import LogoImg from './event_emitter_eltex_loc-32px.png';
|
||||
|
||||
export type TopBarProps = {
|
||||
contact: string;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
const TopBar = (props: TopBarProps): ReactElement => {
|
||||
return (
|
||||
<div className={TopBarCss.top}>
|
||||
<div className={TopBarCss.containerTitle}>
|
||||
<div className={TopBarCss.logo}>
|
||||
<a href="/" className={TopBarCss.logo}>
|
||||
<img src={LogoImg} alt="event_emitter_eltex_loc" className={TopBarCss.eventEmitterEltexLoc} />
|
||||
<span>redmine-issue-event-emitter</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{props.children}
|
||||
|
||||
<p><a href="/" target="_blank"> #документация</a></p>
|
||||
<p><a href={props.contact} target="_blank" rel="noreferrer"> #контакты</a></p>
|
||||
<p><a href="https://gnedov.info/" target="_blank" rel="noreferrer"> #блог</a></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
9
frontend/src/unknown-page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
export const UnknownPage = () => {
|
||||
return (
|
||||
<p>Unknown page</p>
|
||||
)
|
||||
};
|
||||
|
||||
export default UnknownPage;
|
||||
14
frontend/src/utils/service-actions-buttons.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
import { onGetIssuesQueueSizeClick, onIssuesRefreshClick } from './service-actions';
|
||||
|
||||
export const IssuesForceRefreshButton = (): JSX.Element => {
|
||||
return (
|
||||
<button onClick={onIssuesRefreshClick}>Force issues refresh</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const GetIssuesQueueSizeButton = (): JSX.Element => {
|
||||
return (
|
||||
<button onClick={onGetIssuesQueueSizeClick}>Get issues queue size</button>
|
||||
);
|
||||
};
|
||||
21
frontend/src/utils/service-actions.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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);
|
||||
};
|
||||
|
||||
export const onGetIssuesQueueSizeClick = async (e: React.MouseEvent): Promise<void> => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
const resp = await axios.get(`/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}`);
|
||||
};
|
||||
24
frontend/src/utils/style.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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("")
|
||||
);
|
||||
};
|
||||
|
||||
export const getStyleObjectFromString = (str: string): Record<string, string> => {
|
||||
const style = {} as Record<string, string>;
|
||||
str.split(";").forEach(el => {
|
||||
const [property, value] = el.split(":");
|
||||
if (!property) return;
|
||||
|
||||
const formattedProperty = formatStringToCamelCase(property.trim());
|
||||
style[formattedProperty] = value.trim();
|
||||
});
|
||||
|
||||
return style;
|
||||
};
|
||||
23
frontend/src/utils/unreaded-provider.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export function GetIssueReadingTimestamp(issueId: number): number {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getKey(issueId: number): string {
|
||||
return `issue_read_${issueId}`;
|
||||
}
|
||||
26
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { DynamicModule, Logger, Module, OnModuleInit } from '@nestjs/common';
|
|||
import { EventEmitterService } from './event-emitter.service';
|
||||
import { RedmineEventsGateway } from './events/redmine-events.gateway';
|
||||
import MainConfig from './configs/main-config';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { RedmineDataLoader } from './redmine-data-loader/redmine-data-loader';
|
||||
import { MainController } from './main/main.controller';
|
||||
import { ModuleParams } from './models/module-params';
|
||||
|
|
@ -27,6 +27,7 @@ import { ListIssuesByUsersWidgetService } from './project-dashboard/widgets/list
|
|||
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';
|
||||
|
||||
@Module({})
|
||||
export class EventEmitterModule implements OnModuleInit {
|
||||
|
|
@ -58,6 +59,15 @@ export class EventEmitterModule implements OnModuleInit {
|
|||
ListIssuesByUsersLikeJiraWidgetService,
|
||||
TimePassedHighlightEnhancer,
|
||||
ListIssuesByFieldsWidgetService,
|
||||
{
|
||||
provide: 'ISSUES_UPDATER_SERVICE',
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const redminePublicUrl =
|
||||
configService.get<string>('redmineUrlPublic');
|
||||
return new IssuesUpdaterService(redminePublicUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
EventEmitterService,
|
||||
|
|
@ -81,6 +91,10 @@ export class EventEmitterModule implements OnModuleInit {
|
|||
ListIssuesByUsersLikeJiraWidgetService,
|
||||
TimePassedHighlightEnhancer,
|
||||
ListIssuesByFieldsWidgetService,
|
||||
{
|
||||
provide: 'ISSUES_UPDATER_SERVICE',
|
||||
useExisting: 'ISSUES_UPDATER_SERVICE',
|
||||
},
|
||||
],
|
||||
controllers: [MainController, UsersController, IssuesController],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
class SimpleIssueUpdater {
|
||||
constructor(
|
||||
private userApiKey: string,
|
||||
private updater: IssuesUpdaterService,
|
||||
) {}
|
||||
|
||||
async updateIssue(
|
||||
issueId: number,
|
||||
issue: Record<string, any>,
|
||||
): Promise<boolean> {
|
||||
return await this.updater.updateIssue(issueId, issue, this.userApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IssuesUpdaterService {
|
||||
constructor(private redminePublicUrl: string) {}
|
||||
|
||||
createSimpleUpdater(userApiKey: string): SimpleIssueUpdater {
|
||||
return new SimpleIssueUpdater(userApiKey, this);
|
||||
}
|
||||
|
||||
async updateIssue(
|
||||
issueId: number,
|
||||
issue: Record<string, any>,
|
||||
userApiKey: string,
|
||||
): Promise<boolean> {
|
||||
const url = this.getUrl(issueId);
|
||||
const data = { issue: issue };
|
||||
const resp = await axios.put(url, data, {
|
||||
headers: { 'X-Redmine-API-Key': userApiKey },
|
||||
});
|
||||
return Boolean(resp);
|
||||
}
|
||||
|
||||
private getUrl(issueId: number): string {
|
||||
return `${this.redminePublicUrl}/issues/${issueId}.json`;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ export class Queue<T, NT> {
|
|||
|
||||
queue: Subject<NT[]> = new Subject<NT[]>();
|
||||
|
||||
finished: Subject<boolean> = new Subject();
|
||||
|
||||
constructor(
|
||||
private updateInterval: number,
|
||||
private itemsLimit: number,
|
||||
|
|
@ -43,6 +45,9 @@ export class Queue<T, NT> {
|
|||
const items = this.items.splice(0, this.itemsLimit);
|
||||
const transformedItems = await this.transformationFn(items);
|
||||
this.queue.next(transformedItems);
|
||||
if (this.items.length <= 0) {
|
||||
this.finished.next(true);
|
||||
}
|
||||
}
|
||||
this.updateTimeout = setTimeout(() => {
|
||||
this.update();
|
||||
|
|
|
|||
|
|
@ -10,24 +10,6 @@
|
|||
"compilerOptions": {
|
||||
"tsConfigPath": "libs/event-emitter/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"redmine-issues-cache-writer": {
|
||||
"type": "library",
|
||||
"root": "libs/redmine-issues-cache-writer",
|
||||
"entryFile": "index",
|
||||
"sourceRoot": "libs/redmine-issues-cache-writer/src",
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "libs/redmine-issues-cache-writer/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"redmine-data-loader": {
|
||||
"type": "library",
|
||||
"root": "libs/redmine-data-loader",
|
||||
"entryFile": "index",
|
||||
"sourceRoot": "libs/redmine-data-loader/src",
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "libs/redmine-data-loader/tsconfig.lib.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"compilerOptions": {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
export class AppController {}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ import { SimpleKanbanBoardController } from './dashboards/simple-kanban-board.co
|
|||
import { IssueUrlEnhancer } from '@app/event-emitter/issue-enhancers/issue-url-enhancer';
|
||||
import { IssuesByTagsWidgetService } from './dashboards/widgets/issues-by-tags.widget.service';
|
||||
import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to-tags-enhancer';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
import { SimpleIssuesListController } from './dashboards/simple-issues-list.controller';
|
||||
import { TagsManagerController } from './tags-manager/tags-manager.controller';
|
||||
import { CreateTagManagerServiceProvider } from './tags-manager/tags-manager.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -56,6 +61,9 @@ import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to
|
|||
isGlobal: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'frontend', 'build'),
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
AppController,
|
||||
|
|
@ -63,6 +71,8 @@ import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to
|
|||
CurrentIssuesEccmReportController,
|
||||
DailyEccmReportController,
|
||||
SimpleKanbanBoardController,
|
||||
SimpleIssuesListController,
|
||||
TagsManagerController,
|
||||
],
|
||||
providers: [
|
||||
AppService,
|
||||
|
|
@ -94,10 +104,11 @@ import { CategoryMergeToTagsEnhancer } from './issue-enhancers/category-merge-to
|
|||
const eccmProjectName = configService.get<string>(
|
||||
'redmineEccm.projectName',
|
||||
);
|
||||
return new CategoryMergeToTagsEnhancer([eccmProjectName]);
|
||||
return new CategoryMergeToTagsEnhancer([eccmProjectName, 'EDM', 'ELM']);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
CreateTagManagerServiceProvider('TAG_MANAGER_SERVICE'),
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnModuleInit {
|
||||
|
|
|
|||
34
src/dashboards/simple-issues-list.controller.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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 { parse } from 'jsonc-parser';
|
||||
|
||||
@Controller('simple-issues-list')
|
||||
export class SimpleIssuesListController {
|
||||
private path: string;
|
||||
|
||||
constructor(
|
||||
private issuesByTagsWidgetService: IssuesByTagsWidgetService,
|
||||
private dynamicLoader: DynamicLoader,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.path = this.configService.get<string>('simpleKanbanBoard.path');
|
||||
}
|
||||
|
||||
@Get('/by-tags/:name/raw')
|
||||
async getByTagsRawData(@Param('name') name: string): Promise<any> {
|
||||
const cfg = this.dynamicLoader.load(name, {
|
||||
path: this.path,
|
||||
ext: 'jsonc',
|
||||
parser: parse,
|
||||
});
|
||||
return await this.issuesByTagsWidgetService.render(cfg);
|
||||
}
|
||||
|
||||
@Get('/by-tags/:name')
|
||||
@Render('simple-issues-list')
|
||||
async getByTags(@Param('name') name: string): Promise<any> {
|
||||
return await this.getByTagsRawData(name);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,4 +21,9 @@ export type AppConfig = {
|
|||
};
|
||||
telegramBotToken: string;
|
||||
periodValidityNotification: number;
|
||||
tagManager: {
|
||||
updateInterval: number;
|
||||
updateItemsLimit: number;
|
||||
tagsCustomFieldName: string;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
23
src/tags-manager/tags-manager.controller.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Body, Controller, Inject, Post } from '@nestjs/common';
|
||||
import { TagsManagerService, UpdateRule } from './tags-manager.service';
|
||||
|
||||
type UpdateParams = {
|
||||
userApiKey: string;
|
||||
updateRules: UpdateRule[];
|
||||
};
|
||||
|
||||
@Controller('tags-manager')
|
||||
export class TagsManagerController {
|
||||
constructor(
|
||||
@Inject('TAG_MANAGER_SERVICE')
|
||||
private tagsManagerService: TagsManagerService,
|
||||
) {}
|
||||
|
||||
@Post('/update')
|
||||
async update(@Body() params: UpdateParams): Promise<any> {
|
||||
return await this.tagsManagerService.updateTags(
|
||||
params.userApiKey,
|
||||
params.updateRules,
|
||||
);
|
||||
}
|
||||
}
|
||||
268
src/tags-manager/tags-manager.service.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { IssuesUpdaterService } from '@app/event-emitter/issues-updater/issues-updater.service';
|
||||
import { IssuesService } from '@app/event-emitter/issues/issues.service';
|
||||
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
|
||||
import { Queue } from '@app/event-emitter/queue/queue';
|
||||
import { FlatIssuesStore } from '@app/event-emitter/utils/flat-issues-store';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type UpdateRule = {
|
||||
issueIds: number[];
|
||||
add: string[];
|
||||
remove: string[];
|
||||
};
|
||||
|
||||
type UpdateRuleSingleIssue = {
|
||||
issueId: number;
|
||||
issueData: RedmineTypes.Issue | null;
|
||||
userApiKey: string;
|
||||
add: string[];
|
||||
remove: string[];
|
||||
};
|
||||
|
||||
type ModifyResult = {
|
||||
value: string;
|
||||
modified: boolean;
|
||||
};
|
||||
|
||||
type ModifyCustomField = {
|
||||
field?: RedmineTypes.CustomField;
|
||||
modified?: boolean;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
type UpdateResult = {
|
||||
issue?: RedmineTypes.Issue | null;
|
||||
delta?: Record<string, any>;
|
||||
modified?: boolean;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export function CreateTagManagerServiceProvider(providerName: string): any {
|
||||
return {
|
||||
provide: providerName,
|
||||
useFactory: (
|
||||
configService: ConfigService,
|
||||
issuesUpdaterService: IssuesUpdaterService,
|
||||
issuesService: IssuesService,
|
||||
) => {
|
||||
const updateInverval = configService.get<number>(
|
||||
'tagManager.updateInverval',
|
||||
);
|
||||
const updateItemsLimit = configService.get<number>(
|
||||
'tagManager.updateItemsLimit',
|
||||
);
|
||||
const tagsCustomFieldName = configService.get<string>(
|
||||
'tagManager.tagsCustomFieldName',
|
||||
);
|
||||
return new TagsManagerService(
|
||||
issuesUpdaterService,
|
||||
updateInverval,
|
||||
updateItemsLimit,
|
||||
tagsCustomFieldName,
|
||||
issuesService,
|
||||
);
|
||||
},
|
||||
inject: [ConfigService, 'ISSUES_UPDATER_SERVICE', IssuesService],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TagsManagerService {
|
||||
private logger = new Logger(TagsManagerService.name);
|
||||
private queue: Queue<UpdateRuleSingleIssue, UpdateResult>;
|
||||
|
||||
constructor(
|
||||
private issuesUpdaterService: IssuesUpdaterService,
|
||||
private updateInterval: number,
|
||||
private updateItemsLimit: number,
|
||||
private tagsCustomFieldName: string,
|
||||
private issuesService: IssuesService,
|
||||
) {}
|
||||
|
||||
async updateTags(
|
||||
userApiKey: string,
|
||||
updateRules: UpdateRule[],
|
||||
): Promise<void> {
|
||||
this.logger.debug(`Params for tags updates - ${updateRules}`);
|
||||
const rules = await this.createUpdateRulesSingleIssue(
|
||||
userApiKey,
|
||||
updateRules,
|
||||
);
|
||||
const queue = this.getQueue();
|
||||
queue.add(rules);
|
||||
}
|
||||
|
||||
private getQueue(): Queue<UpdateRuleSingleIssue, UpdateResult> {
|
||||
if (!this.queue) {
|
||||
this.queue = new Queue<UpdateRuleSingleIssue, UpdateResult>(
|
||||
this.updateInterval,
|
||||
this.updateItemsLimit,
|
||||
async (rules: UpdateRuleSingleIssue[]) => {
|
||||
const results = [] as UpdateResult[];
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i];
|
||||
let result: UpdateResult;
|
||||
try {
|
||||
result = await this.updateIssue(rule);
|
||||
} catch (ex) {
|
||||
this.logger.error(
|
||||
`Error at execution update task for issueId = ${rule.issueId}, ` +
|
||||
`add = ${JSON.stringify(rule.add)}, ` +
|
||||
`remove = ${JSON.stringify(rule.remove)}, ` +
|
||||
`error = ${ex.message}`,
|
||||
);
|
||||
result = { success: false };
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
);
|
||||
this.queue.start();
|
||||
}
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
private async updateIssue(
|
||||
rule: UpdateRuleSingleIssue,
|
||||
): Promise<UpdateResult> {
|
||||
if (!rule.issueData) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
if (rule.add.length == 0 && rule.remove.length == 0) {
|
||||
return {
|
||||
success: true,
|
||||
delta: {},
|
||||
issue: rule.issueData,
|
||||
modified: false,
|
||||
};
|
||||
}
|
||||
const result = this.modifyTagsForIssue(rule);
|
||||
if (result.modified) {
|
||||
const delta = { custom_fields: [result.field] };
|
||||
const updateResult = await this.issuesUpdaterService.updateIssue(
|
||||
rule.issueId,
|
||||
delta,
|
||||
rule.userApiKey,
|
||||
);
|
||||
return {
|
||||
success: updateResult,
|
||||
delta: delta,
|
||||
issue: rule.issueData,
|
||||
modified: result.modified,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
delta: {},
|
||||
issue: rule.issueData,
|
||||
modified: result.modified,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async createIssuesStore(
|
||||
updateRules: UpdateRule[],
|
||||
): Promise<FlatIssuesStore> {
|
||||
const issuesStore = new FlatIssuesStore();
|
||||
for (let i = 0; i < updateRules.length; i++) {
|
||||
const updateRule = updateRules[i];
|
||||
for (let j = 0; j < updateRule.issueIds.length; j++) {
|
||||
const issueId = updateRule.issueIds[j];
|
||||
issuesStore.push(issueId);
|
||||
}
|
||||
}
|
||||
|
||||
const loader = this.issuesService.createDynamicIssuesLoader();
|
||||
await issuesStore.fillData(loader);
|
||||
|
||||
return issuesStore;
|
||||
}
|
||||
|
||||
private getTagsCustomField(
|
||||
issue: RedmineTypes.Issue,
|
||||
): RedmineTypes.CustomField | null {
|
||||
if (!issue.custom_fields) return null;
|
||||
const customFields = issue.custom_fields;
|
||||
return (
|
||||
customFields.find((cf) => cf.name == this.tagsCustomFieldName) || null
|
||||
);
|
||||
}
|
||||
|
||||
private getTags(tags: string): string[] {
|
||||
return tags
|
||||
.split(/[ ,;]/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => !!s);
|
||||
}
|
||||
|
||||
private modifyTags(
|
||||
tags: string,
|
||||
updateRule: UpdateRuleSingleIssue,
|
||||
): ModifyResult {
|
||||
const t = this.getTags(tags);
|
||||
let modified = false;
|
||||
for (let i = 0; i < updateRule.remove.length; i++) {
|
||||
const tagForRemoving = updateRule.remove[i];
|
||||
const tagIndex = t.indexOf(tagForRemoving);
|
||||
if (tagIndex >= 0) {
|
||||
t.splice(tagIndex, 1);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < updateRule.add.length; i++) {
|
||||
const tagForAdding = updateRule.add[i];
|
||||
const tagIndex = t.indexOf(tagForAdding);
|
||||
if (tagIndex < 0) {
|
||||
t.push(tagForAdding);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
const result = modified ? t.join(' ') : tags;
|
||||
return { value: result, modified: modified };
|
||||
}
|
||||
|
||||
private modifyTagsForIssue(
|
||||
updateRule: UpdateRuleSingleIssue,
|
||||
): ModifyCustomField {
|
||||
const cf = this.getTagsCustomField(updateRule.issueData);
|
||||
if (!cf) return { success: false };
|
||||
const modifyResult = this.modifyTags(cf.value, updateRule);
|
||||
if (modifyResult.modified) cf.value = modifyResult.value;
|
||||
return { success: true, modified: modifyResult.modified, field: cf };
|
||||
}
|
||||
|
||||
private async createUpdateRulesSingleIssue(
|
||||
userApiKey: string,
|
||||
items: UpdateRule[],
|
||||
): Promise<UpdateRuleSingleIssue[]> {
|
||||
const res = [] as UpdateRuleSingleIssue[];
|
||||
const store = await this.createIssuesStore(items);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
for (let j = 0; j < item.issueIds.length; j++) {
|
||||
const issueId = item.issueIds[j];
|
||||
const issue = this.getIssue(store, issueId);
|
||||
res.push({
|
||||
issueId: issueId,
|
||||
issueData: issue,
|
||||
userApiKey: userApiKey,
|
||||
add: item.add,
|
||||
remove: item.remove,
|
||||
});
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
private getIssue(
|
||||
store: FlatIssuesStore,
|
||||
id: number,
|
||||
): RedmineTypes.Issue | null {
|
||||
const result = store.getIssue(id);
|
||||
return result.data ? result.data : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "frontend"]
|
||||
}
|
||||
|
|
|
|||
114
views/simple-issues-list.hbs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Simple Issues List</title>
|
||||
<style>
|
||||
.list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
background-color: rgb(225, 225, 225);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
margin: 5px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-item-description {
|
||||
background-color: rgb(196, 196, 196);
|
||||
border-width: 1px;
|
||||
border-color: rgba(255, 255, 255, 0.2) rgba(96, 96, 96, 0.2) rgba(96, 96, 96, 0.2) rgba(255, 255, 255, 0.2);
|
||||
border-style: solid;
|
||||
margin: 2px;
|
||||
padding: 3px;
|
||||
width: 190px;
|
||||
/*display: flex;*/
|
||||
border-radius: 3px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.kanban-card div {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.kanban-card .kanban-card-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timepassed-dot {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
background-color: #bbb;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.timepassed-dot.hot {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
.timepassed-dot.warm {
|
||||
background-color: orange;
|
||||
}
|
||||
|
||||
.timepassed-dot.comfort {
|
||||
background-color: rgba(255, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.timepassed-dot.breezy {
|
||||
background-color: rgba(0, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.timepassed-dot.cold {
|
||||
background-color: rgba(0, 0, 255, 0.1);
|
||||
}
|
||||
|
||||
.issue-tag {
|
||||
font-size: 8pt;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
padding-left: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{#each this}}
|
||||
{{#if this.metainfo}}
|
||||
<h1 id="{{this.metainfo.title}}">{{this.metainfo.title}} <a href="#{{this.metainfo.title}}">#</a></h1>
|
||||
<div class="list-container">
|
||||
{{#each this.data}}
|
||||
|
||||
{{#each this.issues}}
|
||||
<div class="list-item">
|
||||
<div>
|
||||
<span class="timepassed-dot {{this.timePassedClass}}"></span>
|
||||
<span class="issue-subject"><a href="{{{this.url.url}}}">{{this.tracker.name}} #{{this.id}} - {{this.subject}}</a></span>
|
||||
<span class="issue-status">| {{this.status.name}}</span>
|
||||
<span class="issue-time">| {{this.total_spent_hours}} / {{this.total_estimated_hours}}</span>
|
||||
</div>
|
||||
<div class="tags-container">
|
||||
{{#if this.styledTags}}
|
||||
{{#each this.styledTags}}
|
||||
<span class="issue-tag" style="{{{this.style}}}">{{this.tag}}</span>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -77,12 +77,14 @@
|
|||
<div class="kanban-container">
|
||||
{{#each this.data}}
|
||||
<div class="kanban-column">
|
||||
<div class="kanban-header">{{this.status}}</div>
|
||||
<div class="kanban-header">{{this.status}} ({{this.count}})</div>
|
||||
{{#each this.issues}}
|
||||
<div class="kanban-card">
|
||||
<div class="kanban-card-title"><span class="timepassed-dot {{this.timePassedClass}}"></span> <a href="{{{this.url.url}}}">{{this.tracker.name}} #{{this.id}} - {{this.subject}}</a></div>
|
||||
<div>Исп.: {{this.current_user.name}}</div>
|
||||
<div>Прогресс: {{this.done_ration}}</div>
|
||||
<div>Прио.: {{this.priority.name}}</div>
|
||||
<div>Версия: {{this.fixed_version.name}}</div>
|
||||
<div>Прогресс: {{this.done_ratio}}</div>
|
||||
<div>Трудозатраты: {{this.total_spent_hours}} / {{this.total_estimated_hours}}</div>
|
||||
{{#if this.styledTags}}
|
||||
<div>
|
||||
|
|
|
|||