Добавлен список задач по тегам
This commit is contained in:
parent
ad61df57b9
commit
d60a082327
16 changed files with 344 additions and 16 deletions
|
|
@ -0,0 +1,6 @@
|
||||||
|
.listContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
background-color: rgb(225, 225, 225);
|
||||||
|
}
|
||||||
25
frontend/src/issues-list-board/issues-list-board.tsx
Normal file
25
frontend/src/issues-list-board/issues-list-board.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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}/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 id={props.store.metainfo.title}>{props.store.metainfo.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
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}/>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
frontend/src/issues-list-board/issues-list-boards.tsx
Normal file
23
frontend/src/issues-list-board/issues-list-boards.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React from 'react';
|
||||||
|
import * as IssuesListBoardStore from './store';
|
||||||
|
import * as IssuesListBoardNs from './issues-list-board';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return <>{list}</>;
|
||||||
|
});
|
||||||
14
frontend/src/issues-list-board/issues-list-card.module.css
Normal file
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 {}
|
||||||
34
frontend/src/issues-list-board/issues-list-card.tsx
Normal file
34
frontend/src/issues-list-board/issues-list-card.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
store: IIssueStore
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssuesListCard = observer((props: Props): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className={Css.listItem}>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
64
frontend/src/issues-list-board/store.ts
Normal file
64
frontend/src/issues-list-board/store.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
|
||||||
|
const url = `/simple-issues-list/${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.map((group: {status: string, count: number, issues: any[]}) => {
|
||||||
|
return group.issues
|
||||||
|
}).flat()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
store.setData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPageStore extends Instance<typeof PageStore> {}
|
||||||
|
|
@ -3,6 +3,8 @@ import KanbanCardCss from './kanban-card.module.css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ICardStore } from './store';
|
import { ICardStore } from './store';
|
||||||
import { getStyleObjectFromString } from '../utils/style';
|
import { getStyleObjectFromString } from '../utils/style';
|
||||||
|
import * as TimePassedNs from '../misc-components/time-passed';
|
||||||
|
import * as TagsNs from '../misc-components/tags';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
store: ICardStore
|
store: ICardStore
|
||||||
|
|
@ -30,12 +32,6 @@ export const KanbanCardTag = (props: TagProps): JSX.Element => {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const KanbanCard = observer((props: Props) => {
|
export const KanbanCard = observer((props: Props) => {
|
||||||
let timePassedColorClassName = '';
|
|
||||||
const timePassedIssueValue: string = props.store.issue.timePassedClass;
|
|
||||||
if (timePassedIssueValue && KanbanCardCss[timePassedIssueValue]) {
|
|
||||||
timePassedColorClassName = KanbanCardCss[timePassedIssueValue];
|
|
||||||
}
|
|
||||||
const timePassedClassName = `${KanbanCardCss.timepassedDot} ${timePassedColorClassName}`;
|
|
||||||
let tagsSection = <></>;
|
let tagsSection = <></>;
|
||||||
const tagsParams = props.store.params.fields.find((field) => {
|
const tagsParams = props.store.params.fields.find((field) => {
|
||||||
return field.component === 'tags';
|
return field.component === 'tags';
|
||||||
|
|
@ -45,16 +41,18 @@ export const KanbanCard = observer((props: Props) => {
|
||||||
if (tagsParams && props.store.issue[tagsParams.path]) {
|
if (tagsParams && props.store.issue[tagsParams.path]) {
|
||||||
const tags = props.store.issue[tagsParams.path] as TagProps[];
|
const tags = props.store.issue[tagsParams.path] as TagProps[];
|
||||||
console.debug(`Tags:`, tags); // DEBUG
|
console.debug(`Tags:`, tags); // DEBUG
|
||||||
tagsSection = (
|
tagsSection = <TagsNs.Tags params={{tags: tags}}/>
|
||||||
<div>
|
}
|
||||||
{tagsParams.label || 'Tags'}: {tags.map(tag => <KanbanCardTag tag={tag.tag} style={tag.style} />)}
|
const timePassedParams: TimePassedNs.Params = {
|
||||||
</div>
|
fromIssue: {
|
||||||
);
|
issue: props.store.issue,
|
||||||
|
keyName: 'timePassedClass'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={KanbanCardCss.kanbanCard}>
|
<div className={KanbanCardCss.kanbanCard}>
|
||||||
<div className={KanbanCardCss.kanbanCardTitle}>
|
<div className={KanbanCardCss.kanbanCardTitle}>
|
||||||
<span className={timePassedClassName}></span>
|
<TimePassedNs.TimePassed params={timePassedParams}/>
|
||||||
<a href={props.store.issue.url.url}>{props.store.issue.tracker.name} #{props.store.issue.id} - {props.store.issue.subject}</a>
|
<a href={props.store.issue.url.url}>{props.store.issue.tracker.name} #{props.store.issue.id} - {props.store.issue.subject}</a>
|
||||||
<div>Исп.: {props.store.issue.current_user.name}</div>
|
<div>Исп.: {props.store.issue.current_user.name}</div>
|
||||||
<div>Прио.: {props.store.issue.priority.name}</div>
|
<div>Прио.: {props.store.issue.priority.name}</div>
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,14 @@ export interface IColumnStore extends Instance<typeof ColumnStore> {}
|
||||||
export const MetaInfoStore = types.model({
|
export const MetaInfoStore = types.model({
|
||||||
title: '',
|
title: '',
|
||||||
url: types.maybe(types.string),
|
url: types.maybe(types.string),
|
||||||
rootIssue: types.model({
|
rootIssue: types.maybe(types.model({
|
||||||
id: 0,
|
id: 0,
|
||||||
tracker: types.model({
|
tracker: types.model({
|
||||||
id: 0,
|
id: 0,
|
||||||
name: ''
|
name: ''
|
||||||
}),
|
}),
|
||||||
subject: ''
|
subject: ''
|
||||||
})
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {}
|
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {}
|
||||||
|
|
@ -75,8 +75,6 @@ export type CardField = {
|
||||||
component: string;
|
component: string;
|
||||||
} & Record<string, any>;
|
} & Record<string, any>;
|
||||||
|
|
||||||
export const CardSettings = types.model({});
|
|
||||||
|
|
||||||
export const CardParamsStore = types.optional(
|
export const CardParamsStore = types.optional(
|
||||||
types.model({
|
types.model({
|
||||||
fields: types.array(
|
fields: types.array(
|
||||||
|
|
|
||||||
14
frontend/src/misc-components/issue-href.tsx
Normal file
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
5
frontend/src/misc-components/tag.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.tag {
|
||||||
|
font-size: 8pt;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
17
frontend/src/misc-components/tag.tsx
Normal file
17
frontend/src/misc-components/tag.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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 className={Css.tag} style={inlineStyle}>
|
||||||
|
{props.tag}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/misc-components/tags.tsx
Normal file
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
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
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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { createBrowserRouter } from "react-router-dom";
|
||||||
import StartPage from "./start-page/start-page";
|
import StartPage from "./start-page/start-page";
|
||||||
import UnknownPage from "./unknown-page";
|
import UnknownPage from "./unknown-page";
|
||||||
import { KanbanBoardsPage } from "./kanban-board/kanban-boards-page";
|
import { KanbanBoardsPage } from "./kanban-board/kanban-boards-page";
|
||||||
|
import { IssuesListBoardPage } from "./issues-list-board/issues-list-boards-page";
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -13,6 +14,10 @@ export const router = createBrowserRouter([
|
||||||
path: "/kanban-board/:type/:name",
|
path: "/kanban-board/:type/:name",
|
||||||
element: (<KanbanBoardsPage/>)
|
element: (<KanbanBoardsPage/>)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/issues-list-board/:type/:name",
|
||||||
|
element: (<IssuesListBoardPage/>)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: (<UnknownPage/>)
|
element: (<UnknownPage/>)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue