Форматирование frontend-а приведено к общим правилам с backend-ом с помощью eslint
This commit is contained in:
parent
0e28eba615
commit
0b82ca564a
31 changed files with 1079 additions and 892 deletions
|
|
@ -5,12 +5,12 @@ import App from './App';
|
|||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
document.getElementById('root') as HTMLElement,
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
|
|
|
|||
|
|
@ -5,32 +5,34 @@ import Css from './issues-list-board.module.css';
|
|||
import * as IssuesListCardNs from './issues-list-card';
|
||||
|
||||
export type Props = {
|
||||
store: IBoardStore
|
||||
}
|
||||
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 (
|
||||
<div className={Css.board}>
|
||||
<div className={Css.boardName}>
|
||||
<h2 className={Css.boardHeader} id={props.store.metainfo.title}>{title}</h2>
|
||||
<a href={`#${props.store.metainfo.title}`}>
|
||||
<img src="/images/anchor BLUE.svg" alt="anchor" className={Css.anchorIcon} />
|
||||
</a>
|
||||
</div>
|
||||
<div className={Css.listContainer}>
|
||||
{list}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const list: JSX.Element[] = props.store.data.map((issue) => {
|
||||
return <IssuesListCardNs.IssuesListCard store={issue} key={issue.id} />;
|
||||
});
|
||||
let title: JSX.Element;
|
||||
if (props.store.metainfo.url) {
|
||||
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
|
||||
} else {
|
||||
title = <>{props.store.metainfo.title}</>;
|
||||
}
|
||||
return (
|
||||
<div className={Css.board}>
|
||||
<div className={Css.boardName}>
|
||||
<h2 className={Css.boardHeader} id={props.store.metainfo.title}>
|
||||
{title}
|
||||
</h2>
|
||||
<a href={`#${props.store.metainfo.title}`}>
|
||||
<img
|
||||
src="/images/anchor BLUE.svg"
|
||||
alt="anchor"
|
||||
className={Css.anchorIcon}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div className={Css.listContainer}>{list}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,21 +4,23 @@ 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}/>
|
||||
);
|
||||
};
|
||||
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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,35 +7,37 @@ import { SetIssuesReadingTimestamp } from '../utils/unreaded-provider';
|
|||
import * as ServiceActionsButtons from '../utils/service-actions-buttons';
|
||||
|
||||
export type Props = {
|
||||
store: IssuesListBoardStore.IPageStore
|
||||
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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,44 +11,63 @@ import { SpentHoursToFixed } from '../utils/spent-hours-to-fixed';
|
|||
import { getStyleObjectFromString } from '../utils/style';
|
||||
|
||||
export type Props = {
|
||||
store: IIssueStore
|
||||
store: IIssueStore;
|
||||
};
|
||||
|
||||
export const defaultPriorityStyleKey = 'priorityStyle';
|
||||
|
||||
export const IssuesListCard = observer((props: Props): JSX.Element => {
|
||||
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store);
|
||||
const detailsStore = IssueDetailsDialogNs.Store.create({
|
||||
issue: props.store,
|
||||
visible: false,
|
||||
unreadedFlagStore: unreadedStore
|
||||
});
|
||||
const priorityStyle = getStyleObjectFromString(props.store[defaultPriorityStyleKey]);
|
||||
const tagsNewLine = (props.store.styledTags && props.store.styledTags.length > 0) ? <br/> : null;
|
||||
return (
|
||||
<div className={Css.todoBlock} onClick={(e) => { e.stopPropagation(); detailsStore.show(); }}>
|
||||
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
|
||||
<div className={Css.relevanceColor}>
|
||||
<TimePassedNs.TimePassed params={{ fromIssue: { issue: props.store, keyName: 'timePassedClass' } }} />
|
||||
</div>
|
||||
<div className={Css.importantInformation}>
|
||||
<span className={Css.issueSubject}>
|
||||
<IssueHrefNs.IssueHref
|
||||
url={props.store.url?.url || ''}
|
||||
subject={props.store.subject}
|
||||
tracker={props.store.tracker?.name || ''}
|
||||
id={props.store.id}
|
||||
/>
|
||||
</span>
|
||||
<span> </span>
|
||||
<span className={Css.timeBox}>{SpentHoursToFixed(props.store.total_spent_hours)} / {SpentHoursToFixed(props.store.total_estimated_hours)}</span>
|
||||
{tagsNewLine}
|
||||
<TagsNs.Tags params={{ tags: props.store.styledTags }} />
|
||||
<div className={Css.positionInfo}>
|
||||
<span className={Css.timeBox}>{props.store.status.name}</span><span> </span>
|
||||
<span className={Css.priorityBox} style={priorityStyle}>{props.store.priority.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store);
|
||||
const detailsStore = IssueDetailsDialogNs.Store.create({
|
||||
issue: props.store,
|
||||
visible: false,
|
||||
unreadedFlagStore: unreadedStore,
|
||||
});
|
||||
const priorityStyle = getStyleObjectFromString(
|
||||
props.store[defaultPriorityStyleKey],
|
||||
);
|
||||
const tagsNewLine =
|
||||
props.store.styledTags && props.store.styledTags.length > 0 ? <br /> : null;
|
||||
return (
|
||||
<div
|
||||
className={Css.todoBlock}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
detailsStore.show();
|
||||
}}
|
||||
>
|
||||
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
|
||||
<div className={Css.relevanceColor}>
|
||||
<TimePassedNs.TimePassed
|
||||
params={{
|
||||
fromIssue: { issue: props.store, keyName: 'timePassedClass' },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={Css.importantInformation}>
|
||||
<span className={Css.issueSubject}>
|
||||
<IssueHrefNs.IssueHref
|
||||
url={props.store.url?.url || ''}
|
||||
subject={props.store.subject}
|
||||
tracker={props.store.tracker?.name || ''}
|
||||
id={props.store.id}
|
||||
/>
|
||||
</span>
|
||||
<span> </span>
|
||||
<span className={Css.timeBox}>
|
||||
{SpentHoursToFixed(props.store.total_spent_hours)} /{' '}
|
||||
{SpentHoursToFixed(props.store.total_estimated_hours)}
|
||||
</span>
|
||||
{tagsNewLine}
|
||||
<TagsNs.Tags params={{ tags: props.store.styledTags }} />
|
||||
<div className={Css.positionInfo}>
|
||||
<span className={Css.timeBox}>{props.store.status.name}</span>
|
||||
<span> </span>
|
||||
<span className={Css.priorityBox} style={priorityStyle}>
|
||||
{props.store.priority.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,86 +1,93 @@
|
|||
import { Instance, types } from "mobx-state-tree";
|
||||
import { RedmineTypes } from "../redmine-types";
|
||||
import axios from "axios";
|
||||
import { Instance, types } from 'mobx-state-tree';
|
||||
import { RedmineTypes } from '../redmine-types';
|
||||
import axios from 'axios';
|
||||
|
||||
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
|
||||
|
||||
export interface IIssueStore extends Instance<typeof IssueStore> {}
|
||||
export type IIssueStore = Instance<typeof IssueStore>;
|
||||
|
||||
export const MetaInfoStore = types.model({
|
||||
title: types.string,
|
||||
url: types.maybe(types.string),
|
||||
rootIssue: types.maybe(types.model({
|
||||
id: 0,
|
||||
tracker: types.model({
|
||||
id: 0,
|
||||
name: ''
|
||||
}),
|
||||
subject: ''
|
||||
}))
|
||||
title: types.string,
|
||||
url: types.maybe(types.string),
|
||||
rootIssue: types.maybe(
|
||||
types.model({
|
||||
id: 0,
|
||||
tracker: types.model({
|
||||
id: 0,
|
||||
name: '',
|
||||
}),
|
||||
subject: '',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const BoardStore = types.model({
|
||||
data: types.array(IssueStore),
|
||||
metainfo: MetaInfoStore
|
||||
data: types.array(IssueStore),
|
||||
metainfo: MetaInfoStore,
|
||||
});
|
||||
|
||||
export interface IBoardStore extends Instance<typeof BoardStore> {}
|
||||
export type IBoardStore = Instance<typeof BoardStore>;
|
||||
|
||||
export const PageStore = types.model({
|
||||
loaded: types.boolean,
|
||||
type: types.string,
|
||||
name: types.string,
|
||||
data: types.maybeNull(
|
||||
types.array(BoardStore)
|
||||
)
|
||||
}).actions((self) => {
|
||||
return {
|
||||
setData: (data: any) => {
|
||||
self.data = data;
|
||||
self.loaded = true;
|
||||
}
|
||||
};
|
||||
}).views((self) => {
|
||||
return {
|
||||
get issueIds(): number[] {
|
||||
if (!self.data) return [];
|
||||
const data = self.data;
|
||||
const res = [] as number[];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const itemData = data[i];
|
||||
for (let j = 0; j < itemData.data.length; j++) {
|
||||
const issue = itemData.data[j];
|
||||
if (res.indexOf(issue.id) < 0) {
|
||||
res.push(issue.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
};
|
||||
});
|
||||
export const PageStore = types
|
||||
.model({
|
||||
loaded: 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 = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
|
||||
const resp = await axios.get(url);
|
||||
if (!(resp?.data)) return;
|
||||
|
||||
const data = [];
|
||||
for (let i = 0; i < resp.data.length; i++) {
|
||||
const item = resp.data[i] as {data: any[], metainfo: Record<string, any>};
|
||||
data.push({
|
||||
metainfo: item.metainfo,
|
||||
data: item.data ? item.data.map((group: { status: string, count: number, issues: any[] }) => {
|
||||
return group.issues
|
||||
}).flat() : []
|
||||
});
|
||||
}
|
||||
|
||||
/* DEBUG: begin */
|
||||
console.debug(`Issues list board store data: ${JSON.stringify(data)}`);
|
||||
/* DEBUG: end */
|
||||
|
||||
store.setData(data);
|
||||
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
|
||||
const resp = await axios.get(url);
|
||||
if (!resp?.data) return;
|
||||
|
||||
const data = [];
|
||||
for (let i = 0; i < resp.data.length; i++) {
|
||||
const item = resp.data[i] as { data: any[]; metainfo: Record<string, any> };
|
||||
data.push({
|
||||
metainfo: item.metainfo,
|
||||
data: item.data
|
||||
? item.data
|
||||
.map((group: { status: string; count: number; issues: any[] }) => {
|
||||
return group.issues;
|
||||
})
|
||||
.flat()
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
/* DEBUG: begin */
|
||||
console.debug(`Issues list board store data: ${JSON.stringify(data)}`);
|
||||
/* DEBUG: end */
|
||||
|
||||
store.setData(data);
|
||||
}
|
||||
|
||||
export interface IPageStore extends Instance<typeof PageStore> {}
|
||||
export type IPageStore = Instance<typeof PageStore>;
|
||||
|
|
|
|||
|
|
@ -5,23 +5,26 @@ import { observer } from 'mobx-react-lite';
|
|||
import * as KanbanCard from './kanban-card';
|
||||
|
||||
export type Props = {
|
||||
store: Stores.IColumnStore
|
||||
}
|
||||
store: Stores.IColumnStore;
|
||||
};
|
||||
|
||||
export const Column = observer((props: Props) => {
|
||||
const cards = props.store.cards.map((card) => {
|
||||
return (
|
||||
<KanbanCard.KanbanCard store={card} key={card.issue.id}></KanbanCard.KanbanCard>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className={ColumnCss.kanbanColumn}>
|
||||
<div className={ColumnCss.kanbanHeader}>
|
||||
{props.store.status} ({props.store.count})
|
||||
</div>
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
const cards = props.store.cards.map((card) => {
|
||||
return (
|
||||
<KanbanCard.KanbanCard
|
||||
store={card}
|
||||
key={card.issue.id}
|
||||
></KanbanCard.KanbanCard>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className={ColumnCss.kanbanColumn}>
|
||||
<div className={ColumnCss.kanbanHeader}>
|
||||
{props.store.status} ({props.store.count})
|
||||
</div>
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Column;
|
||||
|
|
|
|||
|
|
@ -5,29 +5,29 @@ import { observer } from 'mobx-react-lite';
|
|||
import Column from './column';
|
||||
|
||||
export type Props = {
|
||||
store: IBoardStore
|
||||
store: IBoardStore;
|
||||
};
|
||||
|
||||
export const KanbanBoard = observer((props: Props) => {
|
||||
let title: any;
|
||||
if (props.store.metainfo.url) {
|
||||
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
|
||||
} else {
|
||||
title = <>{props.store.metainfo.title}</>;
|
||||
}
|
||||
const columns = [];
|
||||
for (let i = 0; i < props.store.data.length; i++) {
|
||||
const column = props.store.data[i];
|
||||
columns.push(<Column store={column}/>)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 id={props.store.metainfo.title}>{title} <a href={`#${props.store.metainfo.title}`}>#</a></h1>
|
||||
<div className={KanbanBoardCss.kanbanContainer}>
|
||||
{columns}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
let title: any;
|
||||
if (props.store.metainfo.url) {
|
||||
title = <a href={props.store.metainfo.url}>{props.store.metainfo.title}</a>;
|
||||
} else {
|
||||
title = <>{props.store.metainfo.title}</>;
|
||||
}
|
||||
const columns = [];
|
||||
for (let i = 0; i < props.store.data.length; i++) {
|
||||
const column = props.store.data[i];
|
||||
columns.push(<Column store={column} />);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 id={props.store.metainfo.title}>
|
||||
{title} <a href={`#${props.store.metainfo.title}`}>#</a>
|
||||
</h1>
|
||||
<div className={KanbanBoardCss.kanbanContainer}>{columns}</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default KanbanBoard;
|
||||
export default KanbanBoard;
|
||||
|
|
|
|||
|
|
@ -8,47 +8,51 @@ import axios from 'axios';
|
|||
import * as ServiceActionsButtons from '../utils/service-actions-buttons';
|
||||
|
||||
export type Props = {
|
||||
store: IPageStore
|
||||
}
|
||||
store: IPageStore;
|
||||
};
|
||||
|
||||
export const KanbanBoards = observer((props: Props) => {
|
||||
const data = props.store.data;
|
||||
if (!props.store.loaded || !data) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const boardData = data[i];
|
||||
const key = boardData.metainfo.title;
|
||||
const board = <KB.KanbanBoard store={boardData} key={key} />;
|
||||
list.push(board);
|
||||
}
|
||||
const topRightMenuStore = TopRightMenuNs.Store.create({visible: false});
|
||||
const onAllReadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
SetIssuesReadingTimestamp(props.store.issueIds);
|
||||
PageStoreLoadData(props.store);
|
||||
};
|
||||
let treeRefreshMenuItem: JSX.Element = <></>;
|
||||
if (props.store.canTreeRefresh) {
|
||||
const onTreeRefreshClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
axios.get(`${process.env.REACT_APP_BACKEND}simple-kanban-board/tree/${props.store.name}/refresh`);
|
||||
}
|
||||
treeRefreshMenuItem = <button onClick={onTreeRefreshClick}>Force tree refresh</button>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
|
||||
<button onClick={onAllReadClick}>Всё прочитано</button>
|
||||
{treeRefreshMenuItem}
|
||||
<ServiceActionsButtons.IssuesForceRefreshButton/>
|
||||
<ServiceActionsButtons.GetIssuesQueueSizeButton/>
|
||||
</TopRightMenuNs.TopRightMenu>
|
||||
{list}
|
||||
</>
|
||||
);
|
||||
const data = props.store.data;
|
||||
if (!props.store.loaded || !data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
const list: any[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const boardData = data[i];
|
||||
const key = boardData.metainfo.title;
|
||||
const board = <KB.KanbanBoard store={boardData} key={key} />;
|
||||
list.push(board);
|
||||
}
|
||||
const topRightMenuStore = TopRightMenuNs.Store.create({ visible: false });
|
||||
const onAllReadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
SetIssuesReadingTimestamp(props.store.issueIds);
|
||||
PageStoreLoadData(props.store);
|
||||
};
|
||||
let treeRefreshMenuItem: JSX.Element = <></>;
|
||||
if (props.store.canTreeRefresh) {
|
||||
const onTreeRefreshClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
axios.get(
|
||||
`${process.env.REACT_APP_BACKEND}simple-kanban-board/tree/${props.store.name}/refresh`,
|
||||
);
|
||||
};
|
||||
treeRefreshMenuItem = (
|
||||
<button onClick={onTreeRefreshClick}>Force tree refresh</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TopRightMenuNs.TopRightMenu store={topRightMenuStore}>
|
||||
<button onClick={onAllReadClick}>Всё прочитано</button>
|
||||
{treeRefreshMenuItem}
|
||||
<ServiceActionsButtons.IssuesForceRefreshButton />
|
||||
<ServiceActionsButtons.GetIssuesQueueSizeButton />
|
||||
</TopRightMenuNs.TopRightMenu>
|
||||
{list}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default KanbanBoards;
|
||||
export default KanbanBoards;
|
||||
|
|
|
|||
|
|
@ -9,70 +9,84 @@ import * as IssueDetailsDialogNs from '../misc-components/issue-details-dialog';
|
|||
import * as UnreadedFlagNs from '../misc-components/unreaded-flag';
|
||||
|
||||
export type Props = {
|
||||
store: ICardStore
|
||||
store: ICardStore;
|
||||
};
|
||||
|
||||
export type TagProps = {
|
||||
style?: string;
|
||||
tag: string;
|
||||
style?: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export const KanbanCardTag = (props: TagProps): JSX.Element => {
|
||||
const inlineStyle = getStyleObjectFromString(props.style || '');
|
||||
return (
|
||||
<span className={KanbanCardCss.kanbanCardTag} style={inlineStyle}>
|
||||
{props.tag}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const inlineStyle = getStyleObjectFromString(props.style || '');
|
||||
return (
|
||||
<span className={KanbanCardCss.kanbanCardTag} style={inlineStyle}>
|
||||
{props.tag}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Какие дальше требования к карточкам?
|
||||
*
|
||||
*
|
||||
* 1. Отобразить как было в статичной доске
|
||||
* 2. Переделать отображение с учётом store.params
|
||||
*/
|
||||
|
||||
export const KanbanCard = observer((props: Props) => {
|
||||
let tagsSection = <></>;
|
||||
const tagsParams = props.store.params.fields.find((field) => {
|
||||
return field.component === 'tags';
|
||||
});
|
||||
console.debug('Tag params:', tagsParams); // DEBUG
|
||||
console.debug('Issue:', props.store.issue); // DEBUG
|
||||
if (tagsParams && props.store.issue[tagsParams.path]) {
|
||||
const tags = props.store.issue[tagsParams.path] as TagProps[];
|
||||
console.debug(`Tags:`, tags); // DEBUG
|
||||
tagsSection = <TagsNs.Tags params={{tags: tags}}/>
|
||||
}
|
||||
const timePassedParams: TimePassedNs.Params = {
|
||||
fromIssue: {
|
||||
issue: props.store.issue,
|
||||
keyName: 'timePassedClass'
|
||||
}
|
||||
}
|
||||
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(props.store.issue);
|
||||
const detailsStore = IssueDetailsDialogNs.Store.create({
|
||||
issue: props.store.issue,
|
||||
visible: false,
|
||||
unreadedFlagStore: unreadedStore
|
||||
});
|
||||
return (
|
||||
<div className={KanbanCardCss.kanbanCard} onClick={(e) => {e.stopPropagation(); detailsStore.show();}}>
|
||||
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
|
||||
<div className={KanbanCardCss.kanbanCardTitle}>
|
||||
<UnreadedFlagNs.UnreadedFlag store={unreadedStore}/>
|
||||
<TimePassedNs.TimePassed params={timePassedParams}/>
|
||||
<a href={props.store.issue.url.url}>{props.store.issue.tracker.name} #{props.store.issue.id} - {props.store.issue.subject}</a>
|
||||
</div>
|
||||
<div>Исп.: {props.store.issue.current_user.name}</div>
|
||||
<div>Прио.: {props.store.issue.priority.name}</div>
|
||||
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div>
|
||||
<div>Прогресс: {props.store.issue.done_ratio}</div>
|
||||
<div>Трудозатраты: {props.store.issue.total_spent_hours} / {props.store.issue.total_estimated_hours}</div>
|
||||
{tagsSection}
|
||||
</div>
|
||||
);
|
||||
let tagsSection = <></>;
|
||||
const tagsParams = props.store.params.fields.find((field) => {
|
||||
return field.component === 'tags';
|
||||
});
|
||||
console.debug('Tag params:', tagsParams); // DEBUG
|
||||
console.debug('Issue:', props.store.issue); // DEBUG
|
||||
if (tagsParams && props.store.issue[tagsParams.path]) {
|
||||
const tags = props.store.issue[tagsParams.path] as TagProps[];
|
||||
console.debug(`Tags:`, tags); // DEBUG
|
||||
tagsSection = <TagsNs.Tags params={{ tags: tags }} />;
|
||||
}
|
||||
const timePassedParams: TimePassedNs.Params = {
|
||||
fromIssue: {
|
||||
issue: props.store.issue,
|
||||
keyName: 'timePassedClass',
|
||||
},
|
||||
};
|
||||
const unreadedStore = UnreadedFlagNs.CreateStoreFromLocalStorage(
|
||||
props.store.issue,
|
||||
);
|
||||
const detailsStore = IssueDetailsDialogNs.Store.create({
|
||||
issue: props.store.issue,
|
||||
visible: false,
|
||||
unreadedFlagStore: unreadedStore,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={KanbanCardCss.kanbanCard}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
detailsStore.show();
|
||||
}}
|
||||
>
|
||||
<IssueDetailsDialogNs.IssueDetailsDialog store={detailsStore} />
|
||||
<div className={KanbanCardCss.kanbanCardTitle}>
|
||||
<UnreadedFlagNs.UnreadedFlag store={unreadedStore} />
|
||||
<TimePassedNs.TimePassed params={timePassedParams} />
|
||||
<a href={props.store.issue.url.url}>
|
||||
{props.store.issue.tracker.name} #{props.store.issue.id} -{' '}
|
||||
{props.store.issue.subject}
|
||||
</a>
|
||||
</div>
|
||||
<div>Исп.: {props.store.issue.current_user.name}</div>
|
||||
<div>Прио.: {props.store.issue.priority.name}</div>
|
||||
<div>Версия: {props.store.issue.fixed_version?.name || ''}</div>
|
||||
<div>Прогресс: {props.store.issue.done_ratio}</div>
|
||||
<div>
|
||||
Трудозатраты: {props.store.issue.total_spent_hours} /{' '}
|
||||
{props.store.issue.total_estimated_hours}
|
||||
</div>
|
||||
{tagsSection}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default KanbanCard;
|
||||
export default KanbanCard;
|
||||
|
|
|
|||
|
|
@ -2,126 +2,128 @@ import { Instance, types } from 'mobx-state-tree';
|
|||
import { RedmineTypes } from '../redmine-types';
|
||||
import axios from 'axios';
|
||||
|
||||
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>()
|
||||
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>();
|
||||
|
||||
export interface IIssueStore extends Instance<typeof IssueStore> {}
|
||||
export type IIssueStore = Instance<typeof IssueStore>;
|
||||
|
||||
export const ColumnStore = types.model({
|
||||
status: '',
|
||||
count: 0,
|
||||
issues: types.array(IssueStore)
|
||||
}).views((self) => {
|
||||
return {
|
||||
get cards(): ICardStore[] {
|
||||
return self.issues.map(issue => {
|
||||
return CardStore.create({
|
||||
issue: issue
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export const ColumnStore = types
|
||||
.model({
|
||||
status: '',
|
||||
count: 0,
|
||||
issues: types.array(IssueStore),
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get cards(): ICardStore[] {
|
||||
return self.issues.map((issue) => {
|
||||
return CardStore.create({
|
||||
issue: issue,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export interface IColumnStore extends Instance<typeof ColumnStore> {}
|
||||
export type IColumnStore = Instance<typeof ColumnStore>;
|
||||
|
||||
export const MetaInfoStore = types.model({
|
||||
title: '',
|
||||
url: types.maybe(types.string),
|
||||
rootIssue: types.maybe(types.model({
|
||||
id: 0,
|
||||
tracker: types.model({
|
||||
id: 0,
|
||||
name: ''
|
||||
}),
|
||||
subject: ''
|
||||
}))
|
||||
title: '',
|
||||
url: types.maybe(types.string),
|
||||
rootIssue: types.maybe(
|
||||
types.model({
|
||||
id: 0,
|
||||
tracker: types.model({
|
||||
id: 0,
|
||||
name: '',
|
||||
}),
|
||||
subject: '',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {}
|
||||
export type IMetaInfoStore = Instance<typeof MetaInfoStore>;
|
||||
|
||||
export const BoardStore = types.model({
|
||||
data: types.array(ColumnStore),
|
||||
metainfo: MetaInfoStore
|
||||
data: types.array(ColumnStore),
|
||||
metainfo: MetaInfoStore,
|
||||
});
|
||||
|
||||
export interface IBoardStore extends Instance<typeof BoardStore> {}
|
||||
export type IBoardStore = Instance<typeof BoardStore>;
|
||||
|
||||
export const PageStore = types.model({
|
||||
loaded: false,
|
||||
type: '',
|
||||
name: '',
|
||||
data: types.maybeNull(
|
||||
types.array(BoardStore)
|
||||
)
|
||||
}).actions(self => {
|
||||
return {
|
||||
setData: (data: any) => {
|
||||
self.data = data;
|
||||
self.loaded = true;
|
||||
}
|
||||
};
|
||||
}).views((self) => {
|
||||
return {
|
||||
get issueIds(): number[] {
|
||||
if (!self.data) return [];
|
||||
const res = [] as number[];
|
||||
for (let i = 0; i < self.data.length; i++) {
|
||||
const iData = self.data[i];
|
||||
for (let j = 0; j < iData.data.length; j++) {
|
||||
const jData = iData.data[j];
|
||||
for (let k = 0; k < jData.issues.length; k++) {
|
||||
const issue = jData.issues[k];
|
||||
if (res.indexOf(issue.id) < 0) {
|
||||
res.push(issue.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
get canTreeRefresh(): boolean {
|
||||
return (self.type === 'tree');
|
||||
}
|
||||
};
|
||||
});
|
||||
export const PageStore = types
|
||||
.model({
|
||||
loaded: false,
|
||||
type: '',
|
||||
name: '',
|
||||
data: types.maybeNull(types.array(BoardStore)),
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
setData: (data: any) => {
|
||||
self.data = data;
|
||||
self.loaded = true;
|
||||
},
|
||||
};
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get issueIds(): number[] {
|
||||
if (!self.data) return [];
|
||||
const res = [] as number[];
|
||||
for (let i = 0; i < self.data.length; i++) {
|
||||
const iData = self.data[i];
|
||||
for (let j = 0; j < iData.data.length; j++) {
|
||||
const jData = iData.data[j];
|
||||
for (let k = 0; k < jData.issues.length; k++) {
|
||||
const issue = jData.issues[k];
|
||||
if (res.indexOf(issue.id) < 0) {
|
||||
res.push(issue.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
get canTreeRefresh(): boolean {
|
||||
return self.type === 'tree';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
|
||||
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
|
||||
const resp = await axios.get(url);
|
||||
if (!(resp?.data)) return;
|
||||
store.setData(resp.data);
|
||||
const url = `${process.env.REACT_APP_BACKEND}simple-kanban-board/${store.type}/${store.name}/raw`;
|
||||
const resp = await axios.get(url);
|
||||
if (!resp?.data) return;
|
||||
store.setData(resp.data);
|
||||
}
|
||||
|
||||
export interface IPageStore extends Instance<typeof PageStore> { }
|
||||
export type IPageStore = Instance<typeof PageStore>;
|
||||
|
||||
export type CardField = {
|
||||
component: string;
|
||||
component: string;
|
||||
} & Record<string, any>;
|
||||
|
||||
export const CardParamsStore = types.optional(
|
||||
types.model({
|
||||
fields: types.array(
|
||||
types.frozen<CardField>()
|
||||
),
|
||||
autoCollapse: types.boolean
|
||||
}),
|
||||
{
|
||||
fields: [
|
||||
{ component: 'text', label: 'Исп.', path: 'current_user.name' },
|
||||
{ component: 'text', label: 'Прио.', path: 'priority.name' },
|
||||
{ component: 'text', label: 'Версия', path: 'fixed_version.name' },
|
||||
{ component: 'text', label: 'Прогресс', path: 'done_ratio' },
|
||||
{ component: 'labor_costs' },
|
||||
{ component: 'tags', label: 'Tags', path: 'styledTags' }
|
||||
],
|
||||
autoCollapse: false,
|
||||
}
|
||||
types.model({
|
||||
fields: types.array(types.frozen<CardField>()),
|
||||
autoCollapse: types.boolean,
|
||||
}),
|
||||
{
|
||||
fields: [
|
||||
{ component: 'text', label: 'Исп.', path: 'current_user.name' },
|
||||
{ component: 'text', label: 'Прио.', path: 'priority.name' },
|
||||
{ component: 'text', label: 'Версия', path: 'fixed_version.name' },
|
||||
{ component: 'text', label: 'Прогресс', path: 'done_ratio' },
|
||||
{ component: 'labor_costs' },
|
||||
{ component: 'tags', label: 'Tags', path: 'styledTags' },
|
||||
],
|
||||
autoCollapse: false,
|
||||
},
|
||||
);
|
||||
|
||||
export const CardStore = types.model({
|
||||
issue: IssueStore,
|
||||
params: CardParamsStore
|
||||
issue: IssueStore,
|
||||
params: CardParamsStore,
|
||||
});
|
||||
|
||||
export interface ICardStore extends Instance<typeof CardStore> {}
|
||||
|
||||
export type ICardStore = Instance<typeof CardStore>;
|
||||
|
|
|
|||
|
|
@ -9,107 +9,118 @@ import { SetIssueReadingTimestamp } from '../utils/unreaded-provider';
|
|||
import axios from 'axios';
|
||||
import * as Luxon from 'luxon';
|
||||
|
||||
export const Store = types.model({
|
||||
visible: types.boolean,
|
||||
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
|
||||
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store)
|
||||
}).actions((self) => {
|
||||
return {
|
||||
hide: () => {
|
||||
console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG
|
||||
self.visible = false;
|
||||
},
|
||||
show: () => {
|
||||
console.debug(`Issue details dialog show: issue_id=${self.issue.id}`); // DEBUG
|
||||
self.visible = true;
|
||||
if (self.unreadedFlagStore) {
|
||||
self.unreadedFlagStore.read();
|
||||
} else {
|
||||
SetIssueReadingTimestamp(self.issue.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
}).views((self) => {
|
||||
return {
|
||||
get displayStyle(): React.CSSProperties {
|
||||
return {display: self.visible ? 'block' : 'none'};
|
||||
}
|
||||
};
|
||||
});
|
||||
export const Store = types
|
||||
.model({
|
||||
visible: types.boolean,
|
||||
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
|
||||
unreadedFlagStore: types.maybe(UnreadedFlagNs.Store),
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
hide: () => {
|
||||
console.debug(`Issue details dialog hide: issue_id=${self.issue.id}`); // DEBUG
|
||||
self.visible = false;
|
||||
},
|
||||
show: () => {
|
||||
console.debug(`Issue details dialog show: issue_id=${self.issue.id}`); // DEBUG
|
||||
self.visible = true;
|
||||
if (self.unreadedFlagStore) {
|
||||
self.unreadedFlagStore.read();
|
||||
} else {
|
||||
SetIssueReadingTimestamp(self.issue.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get displayStyle(): React.CSSProperties {
|
||||
return { display: self.visible ? 'block' : 'none' };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof Store>
|
||||
store: Instance<typeof Store>;
|
||||
};
|
||||
|
||||
export const IssueDetailsDialog = observer((props: Props): JSX.Element => {
|
||||
const onUpdateClick = (e: React.MouseEvent) => {
|
||||
const url = `${process.env.REACT_APP_BACKEND}redmine-event-emitter/append-issues`;
|
||||
axios.post(url, [props.store.issue.id]);
|
||||
};
|
||||
const onCloseClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
props.store.hide();
|
||||
};
|
||||
return (
|
||||
<div className={Css.reset}>
|
||||
<div className={Css.modal} style={props.store.displayStyle} onClick={onCloseClick}>
|
||||
<div className={Css.modalContent}>
|
||||
<h1>
|
||||
<button onClick={onCloseClick}>close</button>
|
||||
<button onClick={onUpdateClick}>force update</button>
|
||||
<IssueHrefNs.IssueHref
|
||||
id={props.store.issue?.id || -1}
|
||||
subject={props.store.issue?.subject || ''}
|
||||
tracker={props.store.issue?.tracker?.name || ''}
|
||||
url={props.store.issue?.url?.url || ''}
|
||||
/>
|
||||
</h1>
|
||||
<hr/>
|
||||
<div>
|
||||
<h2>Описание:</h2>
|
||||
<pre>
|
||||
{props.store.issue.description}
|
||||
</pre>
|
||||
</div>
|
||||
<hr/>
|
||||
<div>
|
||||
<h2>Комментарии:</h2>
|
||||
<Comments details={props.store.issue.journals || []} issue={props.store.issue}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const onUpdateClick = (e: React.MouseEvent) => {
|
||||
const url = `${process.env.REACT_APP_BACKEND}redmine-event-emitter/append-issues`;
|
||||
axios.post(url, [props.store.issue.id]);
|
||||
};
|
||||
const onCloseClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
props.store.hide();
|
||||
};
|
||||
return (
|
||||
<div className={Css.reset}>
|
||||
<div
|
||||
className={Css.modal}
|
||||
style={props.store.displayStyle}
|
||||
onClick={onCloseClick}
|
||||
>
|
||||
<div className={Css.modalContent}>
|
||||
<h1>
|
||||
<button onClick={onCloseClick}>close</button>
|
||||
<button onClick={onUpdateClick}>force update</button>
|
||||
<IssueHrefNs.IssueHref
|
||||
id={props.store.issue?.id || -1}
|
||||
subject={props.store.issue?.subject || ''}
|
||||
tracker={props.store.issue?.tracker?.name || ''}
|
||||
url={props.store.issue?.url?.url || ''}
|
||||
/>
|
||||
</h1>
|
||||
<hr />
|
||||
<div>
|
||||
<h2>Описание:</h2>
|
||||
<pre>{props.store.issue.description}</pre>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<h2>Комментарии:</h2>
|
||||
<Comments
|
||||
details={props.store.issue.journals || []}
|
||||
issue={props.store.issue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const Comments = (props: {details?: RedmineTypes.Journal[], issue: RedmineTypes.ExtendedIssue}): JSX.Element => {
|
||||
const comments = props.details?.filter((detail) => {
|
||||
return Boolean(detail.notes);
|
||||
});
|
||||
if (!comments) {
|
||||
return <>No comments</>
|
||||
}
|
||||
const list = comments.map((detail) => {
|
||||
const key = `issueid_${props.issue.id}_commentid_${detail.id}`;
|
||||
return <Comment data={detail} key={key}/>
|
||||
});
|
||||
return (
|
||||
<>{list}</>
|
||||
);
|
||||
}
|
||||
export const Comments = (props: {
|
||||
details?: RedmineTypes.Journal[];
|
||||
issue: RedmineTypes.ExtendedIssue;
|
||||
}): JSX.Element => {
|
||||
const comments = props.details?.filter((detail) => {
|
||||
return Boolean(detail.notes);
|
||||
});
|
||||
if (!comments) {
|
||||
return <>No comments</>;
|
||||
}
|
||||
const list = comments.map((detail) => {
|
||||
const key = `issueid_${props.issue.id}_commentid_${detail.id}`;
|
||||
return <Comment data={detail} key={key} />;
|
||||
});
|
||||
return <>{list}</>;
|
||||
};
|
||||
|
||||
export const Comment = (props: {data: RedmineTypes.Journal}): JSX.Element => {
|
||||
const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat("dd.MM.yyyy HH:mm");
|
||||
return (
|
||||
<>
|
||||
<h3><span className={Css.dateField}>{date}</span> {props.data.user.name}:</h3>
|
||||
<div>
|
||||
<pre>
|
||||
{props.data.notes || '-'}
|
||||
</pre>
|
||||
</div>
|
||||
<hr/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export const Comment = (props: { data: RedmineTypes.Journal }): JSX.Element => {
|
||||
const date = Luxon.DateTime.fromISO(props.data.created_on).toFormat(
|
||||
'dd.MM.yyyy HH:mm',
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<span className={Css.dateField}>{date}</span> {props.data.user.name}:
|
||||
</h3>
|
||||
<div>
|
||||
<pre>{props.data.notes || '-'}</pre>
|
||||
</div>
|
||||
<hr />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import React from 'react';
|
||||
|
||||
export type Props = {
|
||||
url: string;
|
||||
id: number;
|
||||
subject: string;
|
||||
tracker: string;
|
||||
url: string;
|
||||
id: number;
|
||||
subject: string;
|
||||
tracker: string;
|
||||
};
|
||||
|
||||
export const IssueHref = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<a href={props.url}>{props.tracker} #{props.id} - {props.subject}</a>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<a href={props.url}>
|
||||
{props.tracker} #{props.id} - {props.subject}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import { getStyleObjectFromString } from '../utils/style';
|
|||
import Css from './tag.module.css';
|
||||
|
||||
export type Props = {
|
||||
style?: string;
|
||||
tag: string;
|
||||
style?: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export const Tag = (props: Props): JSX.Element => {
|
||||
const inlineStyle = getStyleObjectFromString(props.style || '');
|
||||
return (
|
||||
<>
|
||||
<span> </span>
|
||||
<span className={Css.tag} style={inlineStyle}>
|
||||
{props.tag}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const inlineStyle = getStyleObjectFromString(props.style || '');
|
||||
return (
|
||||
<>
|
||||
<span> </span>
|
||||
<span className={Css.tag} style={inlineStyle}>
|
||||
{props.tag}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,26 +2,28 @@ import React from 'react';
|
|||
import * as TagNs from './tag';
|
||||
|
||||
export type Params = {
|
||||
label?: string;
|
||||
tags: TagNs.Props[];
|
||||
label?: string;
|
||||
tags: TagNs.Props[];
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
params: Params
|
||||
params: Params;
|
||||
};
|
||||
|
||||
export const Tags = (props: Props): JSX.Element => {
|
||||
if (!props.params.tags) {
|
||||
return (<></>);
|
||||
}
|
||||
let label = props.params.label || '';
|
||||
if (label) label = `${label}: `;
|
||||
const tags = props.params.tags.map((tag) => {
|
||||
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag}/>;
|
||||
}) || [];
|
||||
return (
|
||||
<>
|
||||
{label}{tags}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!props.params.tags) {
|
||||
return <></>;
|
||||
}
|
||||
let label = props.params.label || '';
|
||||
if (label) label = `${label}: `;
|
||||
const tags =
|
||||
props.params.tags.map((tag) => {
|
||||
return <TagNs.Tag tag={tag.tag} style={tag.style} key={tag.tag} />;
|
||||
}) || [];
|
||||
return (
|
||||
<>
|
||||
{label}
|
||||
{tags}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,46 +3,48 @@ import Css from './time-passed.module.css';
|
|||
import { RedmineTypes } from '../redmine-types';
|
||||
|
||||
export type Params = {
|
||||
fromIssue?: {
|
||||
issue: RedmineTypes.ExtendedIssue,
|
||||
keyName: string,
|
||||
},
|
||||
fromValue?: string
|
||||
fromIssue?: {
|
||||
issue: RedmineTypes.ExtendedIssue;
|
||||
keyName: string;
|
||||
};
|
||||
fromValue?: string;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
params: Params
|
||||
params: Params;
|
||||
};
|
||||
|
||||
export const TimePassed = (props: Props): JSX.Element => {
|
||||
if (!props.params.fromIssue && !props.params.fromValue) {
|
||||
return <></>;
|
||||
}
|
||||
let timePassedClassName = ''; // TODO
|
||||
if (props.params.fromIssue) {
|
||||
const { issue, keyName } = props.params.fromIssue;
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(issue[keyName])}`;
|
||||
} else if (props.params.fromValue) {
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(props.params.fromValue)}`;
|
||||
}
|
||||
return (
|
||||
<span className={timePassedClassName}></span>
|
||||
);
|
||||
if (!props.params.fromIssue && !props.params.fromValue) {
|
||||
return <></>;
|
||||
}
|
||||
let timePassedClassName = ''; // TODO
|
||||
if (props.params.fromIssue) {
|
||||
const { issue, keyName } = props.params.fromIssue;
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(
|
||||
issue[keyName],
|
||||
)}`;
|
||||
} else if (props.params.fromValue) {
|
||||
timePassedClassName = `${Css.timepassedDot} ${getClassName(
|
||||
props.params.fromValue,
|
||||
)}`;
|
||||
}
|
||||
return <span className={timePassedClassName}></span>;
|
||||
};
|
||||
|
||||
function getClassName(value: string): string {
|
||||
switch (value) {
|
||||
case 'hot':
|
||||
return Css.hot;
|
||||
case 'warm':
|
||||
return Css.warm;
|
||||
case 'comfort':
|
||||
return Css.comfort;
|
||||
case 'breezy':
|
||||
return Css.breezy;
|
||||
case 'cold':
|
||||
return Css.cold;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
switch (value) {
|
||||
case 'hot':
|
||||
return Css.hot;
|
||||
case 'warm':
|
||||
return Css.warm;
|
||||
case 'comfort':
|
||||
return Css.comfort;
|
||||
case 'breezy':
|
||||
return Css.breezy;
|
||||
case 'cold':
|
||||
return Css.cold;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,53 +3,62 @@ import { Instance, types } from 'mobx-state-tree';
|
|||
import React from 'react';
|
||||
import Css from './top-right-menu.module.css';
|
||||
|
||||
export const Store = types.model({
|
||||
visible: types.boolean
|
||||
}).views((self) => {
|
||||
return {
|
||||
get style(): React.CSSProperties {
|
||||
return {
|
||||
display: self.visible ? 'block' : 'none'
|
||||
};
|
||||
}
|
||||
};
|
||||
}).actions((self) => {
|
||||
return {
|
||||
show: () => {
|
||||
self.visible = true;
|
||||
},
|
||||
hide: () => {
|
||||
self.visible = false;
|
||||
},
|
||||
toggle: () => {
|
||||
self.visible = !self.visible;
|
||||
}
|
||||
};
|
||||
});
|
||||
export const Store = types
|
||||
.model({
|
||||
visible: types.boolean,
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
get style(): React.CSSProperties {
|
||||
return {
|
||||
display: self.visible ? 'block' : 'none',
|
||||
};
|
||||
},
|
||||
};
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
show: () => {
|
||||
self.visible = true;
|
||||
},
|
||||
hide: () => {
|
||||
self.visible = false;
|
||||
},
|
||||
toggle: () => {
|
||||
self.visible = !self.visible;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof Store>;
|
||||
children?: any;
|
||||
store: Instance<typeof Store>;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const TopRightMenu = observer((props: Props): JSX.Element => {
|
||||
const menuItems = [];
|
||||
if (props.children.length > 1) {
|
||||
for (let key = 0; key < props.children.length; key++) {
|
||||
const item = props.children[key];
|
||||
menuItems.push(<li key={key}>{item}</li>);
|
||||
}
|
||||
} else if (props.children) {
|
||||
menuItems.push(<li key={0}>{props.children}</li>)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button className={Css.menuButton} onClick={(e) => {e.stopPropagation(); props.store.toggle();}}>Menu</button>
|
||||
<div className={Css.menu} style={props.store.style}>
|
||||
<ul>
|
||||
{menuItems}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
const menuItems = [];
|
||||
if (props.children.length > 1) {
|
||||
for (let key = 0; key < props.children.length; key++) {
|
||||
const item = props.children[key];
|
||||
menuItems.push(<li key={key}>{item}</li>);
|
||||
}
|
||||
} else if (props.children) {
|
||||
menuItems.push(<li key={0}>{props.children}</li>);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={Css.menuButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.store.toggle();
|
||||
}}
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
<div className={Css.menu} style={props.store.style}>
|
||||
<ul>{menuItems}</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,62 +3,76 @@ import Css from './unreaded-flag.module.css';
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import { Instance, types } from 'mobx-state-tree';
|
||||
import { RedmineTypes } from '../redmine-types';
|
||||
import { GetIssueReadingTimestamp, SetIssueReadingTimestamp } from '../utils/unreaded-provider';
|
||||
import {
|
||||
GetIssueReadingTimestamp,
|
||||
SetIssueReadingTimestamp,
|
||||
} from '../utils/unreaded-provider';
|
||||
|
||||
export const Store = types.model({
|
||||
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
|
||||
readingTimestamp: types.number
|
||||
}).actions((self) => {
|
||||
return {
|
||||
read: () => {
|
||||
self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id);
|
||||
}
|
||||
};
|
||||
}).views((self) => {
|
||||
return {
|
||||
getUpdatedTimestap(): number {
|
||||
if (self.issue.journals) {
|
||||
let lastComment: RedmineTypes.Journal | undefined;
|
||||
for (let i = self.issue.journals.length - 1; i >= 0; i--) {
|
||||
const journal = self.issue.journals[i];
|
||||
if (journal.notes) {
|
||||
lastComment = journal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastComment) {
|
||||
return (new Date(lastComment.created_on)).getTime();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
getClassName(): string {
|
||||
let className = Css.circle;
|
||||
const updatedTimestamp = this.getUpdatedTimestap();
|
||||
if (self.readingTimestamp < updatedTimestamp) {
|
||||
className += ` ${Css.unreaded}`;
|
||||
}
|
||||
console.debug(`Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`); // DEBUG
|
||||
return className;
|
||||
}
|
||||
};
|
||||
});
|
||||
export const Store = types
|
||||
.model({
|
||||
issue: types.frozen<RedmineTypes.ExtendedIssue>(),
|
||||
readingTimestamp: types.number,
|
||||
})
|
||||
.actions((self) => {
|
||||
return {
|
||||
read: () => {
|
||||
self.readingTimestamp = SetIssueReadingTimestamp(self.issue.id);
|
||||
},
|
||||
};
|
||||
})
|
||||
.views((self) => {
|
||||
return {
|
||||
getUpdatedTimestap(): number {
|
||||
if (self.issue.journals) {
|
||||
let lastComment: RedmineTypes.Journal | undefined;
|
||||
for (let i = self.issue.journals.length - 1; i >= 0; i--) {
|
||||
const journal = self.issue.journals[i];
|
||||
if (journal.notes) {
|
||||
lastComment = journal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastComment) {
|
||||
return new Date(lastComment.created_on).getTime();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
getClassName(): string {
|
||||
let className = Css.circle;
|
||||
const updatedTimestamp = this.getUpdatedTimestap();
|
||||
if (self.readingTimestamp < updatedTimestamp) {
|
||||
className += ` ${Css.unreaded}`;
|
||||
}
|
||||
console.debug(
|
||||
`Unreaded flag getClassName: issueId=${self.issue.id}; readingTimestamp=${self.readingTimestamp}; updatedTimestamp=${updatedTimestamp}; className=${className}`,
|
||||
); // DEBUG
|
||||
return className;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export function CreateStoreFromLocalStorage(issue: RedmineTypes.ExtendedIssue) {
|
||||
const timestamp = GetIssueReadingTimestamp(issue.id);
|
||||
return Store.create({
|
||||
issue: issue,
|
||||
readingTimestamp: timestamp
|
||||
});
|
||||
const timestamp = GetIssueReadingTimestamp(issue.id);
|
||||
return Store.create({
|
||||
issue: issue,
|
||||
readingTimestamp: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
store: Instance<typeof Store>
|
||||
}
|
||||
store: Instance<typeof Store>;
|
||||
};
|
||||
|
||||
export const UnreadedFlag = observer((props: Props): JSX.Element => {
|
||||
const className = props.store.getClassName();
|
||||
return (
|
||||
<span className={className} onClick={(e) => {e.stopPropagation(); props.store.read();}}></span>
|
||||
);
|
||||
})
|
||||
const className = props.store.getClassName();
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.store.read();
|
||||
}}
|
||||
></span>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,18 +10,29 @@ export type Props = {
|
|||
|
||||
export const Basement = (props: Props): JSX.Element => {
|
||||
console.debug('routes:', router.routes); // DEBUG
|
||||
|
||||
|
||||
return (
|
||||
<div className={BasementCss.basement}>
|
||||
<div className={BasementCss.basementGrid}>
|
||||
<div className={BasementCss.bottomContacts}>
|
||||
<a href="/">
|
||||
<img src={props.iconUrl} alt="event_emitter_eltex_loc" className={BasementCss.eventEmitterEltexLoc} />
|
||||
<img
|
||||
src={props.iconUrl}
|
||||
alt="event_emitter_eltex_loc"
|
||||
className={BasementCss.eventEmitterEltexLoc}
|
||||
/>
|
||||
<span>redmine-issue-event-emitter</span>
|
||||
</a>
|
||||
<p><a href={props.contactUrl}> Проект
|
||||
<span className={BasementCss.textBoxTextOrange}> Павел Гнедов</span>
|
||||
</a></p>
|
||||
<p>
|
||||
<a href={props.contactUrl}>
|
||||
{' '}
|
||||
Проект
|
||||
<span className={BasementCss.textBoxTextOrange}>
|
||||
{' '}
|
||||
Павел Гнедов
|
||||
</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={BasementCss.discuss}>
|
||||
|
|
@ -30,8 +41,12 @@ export const Basement = (props: Props): JSX.Element => {
|
|||
<p className={BasementCss.discussText}> ОБСУДИТЬ </p>
|
||||
</a>
|
||||
</div>
|
||||
<img src={props.characterUrl} width="100" alt="Сharacter" className={BasementCss.character02} />
|
||||
|
||||
<img
|
||||
src={props.characterUrl}
|
||||
width="100"
|
||||
alt="Сharacter"
|
||||
className={BasementCss.character02}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
export type Props = {
|
||||
title: string;
|
||||
children?: any;
|
||||
title: string;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const ContentBlock = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<h2>{props.title}</h2>
|
||||
{props.children}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<h2>{props.title}</h2>
|
||||
{props.children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentBlock;
|
||||
export default ContentBlock;
|
||||
|
|
|
|||
|
|
@ -2,15 +2,11 @@ import React from 'react';
|
|||
import ContentCss from './content.module.css';
|
||||
|
||||
export type Props = {
|
||||
children?: any;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const Content = (props: Props) => {
|
||||
return (
|
||||
<div className={ContentCss.content}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
return <div className={ContentCss.content}>{props.children}</div>;
|
||||
};
|
||||
|
||||
export default Content;
|
||||
export default Content;
|
||||
|
|
|
|||
|
|
@ -2,22 +2,26 @@ import React from 'react';
|
|||
import CoverCss from './cover.module.css';
|
||||
|
||||
export type CoverProps = {
|
||||
telegramBotUrl: string;
|
||||
telegramBotUrl: string;
|
||||
};
|
||||
|
||||
export const Cover = (props: CoverProps) => {
|
||||
return (
|
||||
<div className={CoverCss.cover}>
|
||||
<img src="/images/Сharacter_01.png" alt="Сharacter" className={CoverCss.character} />
|
||||
<div className={CoverCss.info}>
|
||||
<h3>Redmine Issue Event Emitter</h3>
|
||||
<h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1>
|
||||
<h4>
|
||||
<a href={props.telegramBotUrl}> ссылка на телеграмм бота</a>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={CoverCss.cover}>
|
||||
<img
|
||||
src="/images/Сharacter_01.png"
|
||||
alt="Сharacter"
|
||||
className={CoverCss.character}
|
||||
/>
|
||||
<div className={CoverCss.info}>
|
||||
<h3>Redmine Issue Event Emitter</h3>
|
||||
<h1>ОБРАБОТКА И АНАЛИЗ ЗАДАЧ ИЗ "REDMINE"</h1>
|
||||
<h4>
|
||||
<a href={props.telegramBotUrl}> ссылка на телеграмм бота</a>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cover;
|
||||
export default Cover;
|
||||
|
|
|
|||
|
|
@ -2,26 +2,34 @@ import React from 'react';
|
|||
import NotificationBlockCss from './notification-block.module.css';
|
||||
|
||||
export type Props = {
|
||||
avatarUrl: string;
|
||||
taskTitle?: string;
|
||||
children?: any;
|
||||
avatarUrl: string;
|
||||
taskTitle?: string;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const NotificationBlock = (props: Props) => {
|
||||
const taskTitle = props?.taskTitle
|
||||
? (<span className={NotificationBlockCss.text_box_text_blue}>{props.taskTitle} </span>)
|
||||
: (<></>);
|
||||
return (
|
||||
<div className={NotificationBlockCss.message}>
|
||||
<img src={props.avatarUrl} alt="event_emitter_eltex_loc" className={NotificationBlockCss.event_emitter_eltex_loc_icon} />
|
||||
<div className={NotificationBlockCss.text_box}>
|
||||
<p className={NotificationBlockCss.text_box_text}>
|
||||
{taskTitle}
|
||||
{props.children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const taskTitle = props?.taskTitle ? (
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>
|
||||
{props.taskTitle}{' '}
|
||||
</span>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
return (
|
||||
<div className={NotificationBlockCss.message}>
|
||||
<img
|
||||
src={props.avatarUrl}
|
||||
alt="event_emitter_eltex_loc"
|
||||
className={NotificationBlockCss.event_emitter_eltex_loc_icon}
|
||||
/>
|
||||
<div className={NotificationBlockCss.text_box}>
|
||||
<p className={NotificationBlockCss.text_box_text}>
|
||||
{taskTitle}
|
||||
{props.children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationBlock;
|
||||
export default NotificationBlock;
|
||||
|
|
|
|||
|
|
@ -9,95 +9,132 @@ import StartPageCss from './start-page.module.css';
|
|||
import TopBar from './top-bar';
|
||||
|
||||
export const StartPageData = {
|
||||
contact: 'https://t.me/pavelgnedov',
|
||||
bot: 'https://t.me/eltex_event_emitter_bot'
|
||||
contact: 'https://t.me/pavelgnedov',
|
||||
bot: 'https://t.me/eltex_event_emitter_bot',
|
||||
};
|
||||
|
||||
export const StartPage = () => {
|
||||
return (
|
||||
<div className={StartPageCss.startPage}>
|
||||
<TopBar contact={StartPageData.contact} />
|
||||
<Cover telegramBotUrl={StartPageData.bot} />
|
||||
<Content>
|
||||
<ContentBlock title='Возможности'>
|
||||
<ul>
|
||||
<li>Уведомления в реальном времени о событиях из задач - изменения статусов, упоминания комментариев</li>
|
||||
<li>Генерация и управления отчётами о задачах</li>
|
||||
<li>Под капотом приложение фреймворк</li>
|
||||
</ul>
|
||||
</ContentBlock>
|
||||
<ContentBlock title='Функции telegram бота'>
|
||||
<ul>
|
||||
<li>Последний отчёт для дейли проект ECCM</li>
|
||||
<li>Дополнительные функции для разработчиков
|
||||
eccm:/current_issues_eccm - список текущих задач по статусам - выбираютсятолько задачи из актуальных версий в статусах, где нужна какая-то реакцияили возможна работа прямо сейчас</li>
|
||||
<li>Скриншоты уведомления от бота:
|
||||
Примеры уведомлений о новых задачах и об изменениях статусов:</li>
|
||||
</ul>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Feature #245005'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Реализовать поддержку нового протокола: <br/><br/>
|
||||
Стив Джобс изменил статус задачи с Feedback на Closed
|
||||
</NotificationBlock>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Feature #241201'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Добавить поддержку новых моделей: <br/><br/>
|
||||
return (
|
||||
<div className={StartPageCss.startPage}>
|
||||
<TopBar contact={StartPageData.contact} />
|
||||
<Cover telegramBotUrl={StartPageData.bot} />
|
||||
<Content>
|
||||
<ContentBlock title="Возможности">
|
||||
<ul>
|
||||
<li>
|
||||
Уведомления в реальном времени о событиях из задач - изменения
|
||||
статусов, упоминания комментариев
|
||||
</li>
|
||||
<li>Генерация и управления отчётами о задачах</li>
|
||||
<li>Под капотом приложение фреймворк</li>
|
||||
</ul>
|
||||
</ContentBlock>
|
||||
<ContentBlock title="Функции telegram бота">
|
||||
<ul>
|
||||
<li>Последний отчёт для дейли проект ECCM</li>
|
||||
<li>
|
||||
Дополнительные функции для разработчиков eccm:/current_issues_eccm
|
||||
- список текущих задач по статусам - выбираютсятолько задачи из
|
||||
актуальных версий в статусах, где нужна какая-то реакцияили
|
||||
возможна работа прямо сейчас
|
||||
</li>
|
||||
<li>
|
||||
Скриншоты уведомления от бота: Примеры уведомлений о новых задачах
|
||||
и об изменениях статусов:
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
Билл Гейтс создал новую задачу и назначил её на вас
|
||||
</NotificationBlock>
|
||||
|
||||
<p>Простые уведомления о движении задач - и больше ничего лишнего.
|
||||
Пример уведомления по личному упоминанию в задаче:
|
||||
</p>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Question #230033'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Сергей Брин:<br/><br/>
|
||||
<NotificationBlock
|
||||
taskTitle="Feature #245005"
|
||||
avatarUrl="/images/event_emitter_eltex_loc-49px.png"
|
||||
>
|
||||
Реализовать поддержку нового протокола: <br />
|
||||
<br />
|
||||
Стив Джобс изменил статус задачи с Feedback на Closed
|
||||
</NotificationBlock>
|
||||
|
||||
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по описанию к этой задаче.
|
||||
</NotificationBlock>
|
||||
|
||||
<NotificationBlock
|
||||
taskTitle='Bug #191122'
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Исправление уязвимости<br/><br/>
|
||||
<NotificationBlock
|
||||
taskTitle="Feature #241201"
|
||||
avatarUrl="/images/event_emitter_eltex_loc-49px.png"
|
||||
>
|
||||
Добавить поддержку новых моделей: <br />
|
||||
<br />
|
||||
Билл Гейтс создал новую задачу и назначил её на вас
|
||||
</NotificationBlock>
|
||||
|
||||
Линус Торвальдс завершил разработку по задаче и передал вам на ревью<br/><br/>
|
||||
<p>
|
||||
Простые уведомления о движении задач - и больше ничего лишнего.
|
||||
Пример уведомления по личному упоминанию в задаче:
|
||||
</p>
|
||||
|
||||
Кажется получилось поправить проблемку. Глянь мой MR.
|
||||
</NotificationBlock>
|
||||
|
||||
<p>Можно задавать коллегам вопросы прямо из комментария задачи, неотрываясь от её содержимого. Уведомление доставится в считанные минуты с ссылкой на задачу и информацией от кого это уведомление.</p>
|
||||
<p>Пример запроса моих текущих задач с помощью команды
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>/current_issues_eccm</span>
|
||||
</p>
|
||||
|
||||
<NotificationBlock
|
||||
avatarUrl='/images/event_emitter_eltex_loc-49px.png'
|
||||
>
|
||||
Бьёрн Страуструп:<br/><br/>
|
||||
<NotificationBlock
|
||||
taskTitle="Question #230033"
|
||||
avatarUrl="/images/event_emitter_eltex_loc-49px.png"
|
||||
>
|
||||
Сергей Брин:
|
||||
<br />
|
||||
<br />
|
||||
@Ларри Пейдж@, у меня есть хорошая идея. Посмотри, пожалуйста, по
|
||||
описанию к этой задаче.
|
||||
</NotificationBlock>
|
||||
|
||||
Re-opened:<br/><br/>
|
||||
<span className={NotificationBlockCss.text_box_text_blue}> - Feature #223301: </span>
|
||||
Дополнить stdlib новыми функциями (прио - P4, версия - C++23)<br/><br/>
|
||||
In Progress:<br/><br/>
|
||||
<span className={NotificationBlockCss.text_box_text_blue}> - Question #223411:</span>
|
||||
Выпуск релиза C++23 (прио - P4, версия - C++23)
|
||||
</NotificationBlock>
|
||||
</ContentBlock>
|
||||
</Content>
|
||||
<Basement contactUrl={StartPageData.contact} characterUrl='/images/Сharacter_02.png' iconUrl='/images/event_emitter_eltex_loc-32px.png'/>
|
||||
</div>
|
||||
);
|
||||
<NotificationBlock
|
||||
taskTitle="Bug #191122"
|
||||
avatarUrl="/images/event_emitter_eltex_loc-49px.png"
|
||||
>
|
||||
Исправление уязвимости
|
||||
<br />
|
||||
<br />
|
||||
Линус Торвальдс завершил разработку по задаче и передал вам на ревью
|
||||
<br />
|
||||
<br />
|
||||
Кажется получилось поправить проблемку. Глянь мой MR.
|
||||
</NotificationBlock>
|
||||
|
||||
<p>
|
||||
Можно задавать коллегам вопросы прямо из комментария задачи,
|
||||
неотрываясь от её содержимого. Уведомление доставится в считанные
|
||||
минуты с ссылкой на задачу и информацией от кого это уведомление.
|
||||
</p>
|
||||
<p>
|
||||
Пример запроса моих текущих задач с помощью команды
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>
|
||||
/current_issues_eccm
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<NotificationBlock avatarUrl="/images/event_emitter_eltex_loc-49px.png">
|
||||
Бьёрн Страуструп:
|
||||
<br />
|
||||
<br />
|
||||
Re-opened:
|
||||
<br />
|
||||
<br />
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>
|
||||
{' '}
|
||||
- Feature #223301:{' '}
|
||||
</span>
|
||||
Дополнить stdlib новыми функциями (прио - P4, версия - C++23)
|
||||
<br />
|
||||
<br />
|
||||
In Progress:
|
||||
<br />
|
||||
<br />
|
||||
<span className={NotificationBlockCss.text_box_text_blue}>
|
||||
{' '}
|
||||
- Question #223411:
|
||||
</span>
|
||||
Выпуск релиза C++23 (прио - P4, версия - C++23)
|
||||
</NotificationBlock>
|
||||
</ContentBlock>
|
||||
</Content>
|
||||
<Basement
|
||||
contactUrl={StartPageData.contact}
|
||||
characterUrl="/images/Сharacter_02.png"
|
||||
iconUrl="/images/event_emitter_eltex_loc-32px.png"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartPage;
|
||||
export default StartPage;
|
||||
|
|
|
|||
|
|
@ -3,29 +3,48 @@ import TopBarCss from './top-bar.module.css';
|
|||
import LogoImg from './event_emitter_eltex_loc-32px.png';
|
||||
|
||||
export type TopBarProps = {
|
||||
contact: string;
|
||||
children?: any;
|
||||
contact: string;
|
||||
children?: any;
|
||||
};
|
||||
|
||||
const TopBar = (props: TopBarProps): ReactElement => {
|
||||
return (
|
||||
<div className={TopBarCss.top}>
|
||||
<div className={TopBarCss.containerTitle}>
|
||||
<div className={TopBarCss.logo}>
|
||||
<a href="/" className={TopBarCss.logo}>
|
||||
<img src={LogoImg} alt="event_emitter_eltex_loc" className={TopBarCss.eventEmitterEltexLoc} />
|
||||
<span>redmine-issue-event-emitter</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{props.children}
|
||||
return (
|
||||
<div className={TopBarCss.top}>
|
||||
<div className={TopBarCss.containerTitle}>
|
||||
<div className={TopBarCss.logo}>
|
||||
<a href="/" className={TopBarCss.logo}>
|
||||
<img
|
||||
src={LogoImg}
|
||||
alt="event_emitter_eltex_loc"
|
||||
className={TopBarCss.eventEmitterEltexLoc}
|
||||
/>
|
||||
<span>redmine-issue-event-emitter</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p><a href="/" target="_blank"> #документация</a></p>
|
||||
<p><a href={props.contact} target="_blank" rel="noreferrer"> #контакты</a></p>
|
||||
<p><a href="https://gnedov.info/" target="_blank" rel="noreferrer"> #блог</a></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{props.children}
|
||||
|
||||
<p>
|
||||
<a href="/" target="_blank">
|
||||
{' '}
|
||||
#документация
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href={props.contact} target="_blank" rel="noreferrer">
|
||||
{' '}
|
||||
#контакты
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://gnedov.info/" target="_blank" rel="noreferrer">
|
||||
{' '}
|
||||
#блог
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const UnknownPage = () => {
|
||||
return (
|
||||
<p>Unknown page</p>
|
||||
)
|
||||
return <p>Unknown page</p>;
|
||||
};
|
||||
|
||||
export default UnknownPage;
|
||||
export default UnknownPage;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import React from 'react';
|
||||
import { onGetIssuesQueueSizeClick, onIssuesRefreshClick } from './service-actions';
|
||||
import {
|
||||
onGetIssuesQueueSizeClick,
|
||||
onIssuesRefreshClick,
|
||||
} from './service-actions';
|
||||
|
||||
export const IssuesForceRefreshButton = (): JSX.Element => {
|
||||
return (
|
||||
<button onClick={onIssuesRefreshClick}>Force issues refresh</button>
|
||||
);
|
||||
return <button onClick={onIssuesRefreshClick}>Force issues refresh</button>;
|
||||
};
|
||||
|
||||
export const GetIssuesQueueSizeButton = (): JSX.Element => {
|
||||
return (
|
||||
<button onClick={onGetIssuesQueueSizeClick}>Get issues queue size</button>
|
||||
);
|
||||
return (
|
||||
<button onClick={onGetIssuesQueueSizeClick}>Get issues queue size</button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,31 @@
|
|||
import axios from "axios";
|
||||
import axios from 'axios';
|
||||
import React from 'react';
|
||||
|
||||
export const onIssuesRefreshClick = (e: React.MouseEvent) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
const rawInput = prompt("Force issues refresh (delimiters - space, comma, semicolon or tab)", "");
|
||||
if (!rawInput) return;
|
||||
const list = rawInput.split(/[ ,;\t\n\r]/).map(item => Number(item)).filter(item => (Number.isFinite(item) && item > 0));
|
||||
if (!list) return;
|
||||
axios.post(`/redmine-event-emitter/append-issues`, list);
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
const rawInput = prompt(
|
||||
'Force issues refresh (delimiters - space, comma, semicolon or tab)',
|
||||
'',
|
||||
);
|
||||
if (!rawInput) return;
|
||||
const list = rawInput
|
||||
.split(/[ ,;\t\n\r]/)
|
||||
.map((item) => Number(item))
|
||||
.filter((item) => Number.isFinite(item) && item > 0);
|
||||
if (!list) return;
|
||||
axios.post(`/redmine-event-emitter/append-issues`, list);
|
||||
};
|
||||
|
||||
export const onGetIssuesQueueSizeClick = async (e: React.MouseEvent): Promise<void> => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
const resp = await axios.get(`${process.env.REACT_APP_BACKEND}redmine-event-emitter/get-issues-queue-size`);
|
||||
console.debug(`resp -`, resp); // DEBUG
|
||||
if (!resp || typeof resp.data !== 'number') return;
|
||||
alert(`Issues queue size - ${resp.data}`);
|
||||
export const onGetIssuesQueueSizeClick = async (
|
||||
e: React.MouseEvent,
|
||||
): Promise<void> => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.stopPropagation();
|
||||
const resp = await axios.get(
|
||||
`${process.env.REACT_APP_BACKEND}redmine-event-emitter/get-issues-queue-size`,
|
||||
);
|
||||
console.debug(`resp -`, resp); // DEBUG
|
||||
if (!resp || typeof resp.data !== 'number') return;
|
||||
alert(`Issues queue size - ${resp.data}`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
/**
|
||||
* Форматирование чисел для вывода трудозатрат
|
||||
* @param a
|
||||
* @returns
|
||||
* @param a
|
||||
* @returns
|
||||
*/
|
||||
export const SpentHoursToFixed = (a: number|string|null|undefined): string => {
|
||||
if (a === null || typeof a === 'undefined') return '-';
|
||||
const res = (typeof a === 'number') ? a : Number(a);
|
||||
if (!Number.isFinite(res)) return '-';
|
||||
return `${parseFloat(res.toFixed(1))}`;
|
||||
};
|
||||
export const SpentHoursToFixed = (
|
||||
a: number | string | null | undefined,
|
||||
): string => {
|
||||
if (a === null || typeof a === 'undefined') return '-';
|
||||
const res = typeof a === 'number' ? a : Number(a);
|
||||
if (!Number.isFinite(res)) return '-';
|
||||
return `${parseFloat(res.toFixed(1))}`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
const formatStringToCamelCase = (str: string): string => {
|
||||
const splitted = str.split("-");
|
||||
if (splitted.length === 1) return splitted[0];
|
||||
return (
|
||||
splitted[0] +
|
||||
splitted
|
||||
.slice(1)
|
||||
.map(word => word[0].toUpperCase() + word.slice(1))
|
||||
.join("")
|
||||
);
|
||||
const splitted = str.split('-');
|
||||
if (splitted.length === 1) return splitted[0];
|
||||
return (
|
||||
splitted[0] +
|
||||
splitted
|
||||
.slice(1)
|
||||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
export const getStyleObjectFromString = (str: string): Record<string, string> => {
|
||||
const style = {} as Record<string, string>;
|
||||
str.split(";").forEach(el => {
|
||||
const [property, value] = el.split(":");
|
||||
if (!property) return;
|
||||
export const getStyleObjectFromString = (
|
||||
str: string,
|
||||
): Record<string, string> => {
|
||||
const style = {} as Record<string, string>;
|
||||
str.split(';').forEach((el) => {
|
||||
const [property, value] = el.split(':');
|
||||
if (!property) return;
|
||||
|
||||
const formattedProperty = formatStringToCamelCase(property.trim());
|
||||
style[formattedProperty] = value.trim();
|
||||
});
|
||||
const formattedProperty = formatStringToCamelCase(property.trim());
|
||||
style[formattedProperty] = value.trim();
|
||||
});
|
||||
|
||||
return style;
|
||||
};
|
||||
return style;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
export function GetIssueReadingTimestamp(issueId: number): number {
|
||||
const value = window.localStorage.getItem(getKey(issueId));
|
||||
return value ? Number(value) : 0;
|
||||
const value = window.localStorage.getItem(getKey(issueId));
|
||||
return value ? Number(value) : 0;
|
||||
}
|
||||
|
||||
export function SetIssueReadingTimestamp(issueId: number): number {
|
||||
const now = (new Date()).getTime();
|
||||
window.localStorage.setItem(getKey(issueId), String(now));
|
||||
return now;
|
||||
const now = new Date().getTime();
|
||||
window.localStorage.setItem(getKey(issueId), String(now));
|
||||
return now;
|
||||
}
|
||||
|
||||
export function SetIssuesReadingTimestamp(issueIds: number[]): number {
|
||||
const now = (new Date()).getTime();
|
||||
for (let i = 0; i < issueIds.length; i++) {
|
||||
const issueId = issueIds[i];
|
||||
window.localStorage.setItem(getKey(issueId), String(now));
|
||||
}
|
||||
return now;
|
||||
const now = new Date().getTime();
|
||||
for (let i = 0; i < issueIds.length; i++) {
|
||||
const issueId = issueIds[i];
|
||||
window.localStorage.setItem(getKey(issueId), String(now));
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
function getKey(issueId: number): string {
|
||||
return `issue_read_${issueId}`;
|
||||
}
|
||||
return `issue_read_${issueId}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue