Kanban доска перенесена в react и mobx

This commit is contained in:
Pavel Gnedov 2023-06-16 20:14:53 +07:00
parent e885128501
commit 6e2d8c25f9
15 changed files with 610 additions and 4 deletions

View file

@ -15,7 +15,9 @@
"@types/node": "^16.18.14",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"axios": "^1.4.0",
"mobx": "^6.9.0",
"mobx-react-lite": "^3.4.3",
"mobx-state-tree": "^5.1.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -4826,6 +4828,29 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/axobject-query": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
@ -11869,6 +11894,27 @@
"url": "https://opencollective.com/mobx"
}
},
"node_modules/mobx-react-lite": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz",
"integrity": "sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mobx"
},
"peerDependencies": {
"mobx": "^6.1.0",
"react": "^16.8.0 || ^17 || ^18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/mobx-state-tree": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/mobx-state-tree/-/mobx-state-tree-5.1.8.tgz",
@ -13820,6 +13866,11 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@ -20369,6 +20420,28 @@
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.6.3.tgz",
"integrity": "sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg=="
},
"axios": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
"requires": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
},
"dependencies": {
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
}
}
},
"axobject-query": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
@ -25482,6 +25555,12 @@
"resolved": "https://registry.npmjs.org/mobx/-/mobx-6.9.0.tgz",
"integrity": "sha512-HdKewQEREEJgsWnErClfbFoVebze6rGazxFLU/XUyrII8dORfVszN1V0BMRnQSzcgsNNtkX8DHj3nC6cdWE9YQ=="
},
"mobx-react-lite": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz",
"integrity": "sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==",
"requires": {}
},
"mobx-state-tree": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/mobx-state-tree/-/mobx-state-tree-5.1.8.tgz",
@ -26702,6 +26781,11 @@
}
}
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",

View file

@ -10,7 +10,9 @@
"@types/node": "^16.18.14",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"axios": "^1.4.0",
"mobx": "^6.9.0",
"mobx-react-lite": "^3.4.3",
"mobx-state-tree": "^5.1.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View file

@ -0,0 +1,11 @@
.kanbanColumn {
width: 200px;
background-color: rgb(225, 225, 225);
display: flex;
flex-direction: column;
}
.kanbanHeader {
text-align: center;
width: 200px;
}

View file

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

View file

@ -0,0 +1,5 @@
.kanbanContainer {
display: flex;
flex-direction: row;
width: 100%;
}

View file

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

View file

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

View file

@ -0,0 +1,29 @@
import React from 'react';
import * as KB from './kanban-board';
import { IPageStore } from './store';
import { observer } from 'mobx-react-lite';
export type Props = {
store: IPageStore
}
export const KanbanBoards = observer((props: Props) => {
const data = props.store.data;
if (!props.store.loaded || !data) {
return <div>Loading...</div>
}
const list: any[] = [];
for (let i = 0; i < data.length; i++) {
const boardData = data[i];
const key = boardData.metainfo.title;
const board = <KB.KanbanBoard store={boardData} key={key} />;
list.push(board);
}
return (
<>
{list}
</>
);
});
export default KanbanBoards;

View file

@ -0,0 +1,56 @@
.kanbanCard {
background-color: rgb(196, 196, 196);
border-width: 1px;
border-color: rgba(255, 255, 255, 0.2) rgba(96, 96, 96, 0.2) rgba(96, 96, 96, 0.2) rgba(255, 255, 255, 0.2);
/*border-color: black;*/
border-style: solid;
margin: 2px;
padding: 3px;
width: 190px;
/*display: flex;*/
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
border-radius: 3px;
z-index: 100;
}
.kanbanCard div {
font-size: small;
}
.kanbanCard .kanbanCardTitle {
font-weight: bold;
}
.timepassedDot {
height: 10px;
width: 10px;
background-color: #bbb;
border-radius: 50%;
display: inline-block;
}
.timepassedDot.hot {
background-color: red;
}
.timepassedDot.warm {
background-color: orange;
}
.timepassedDot.comfort {
background-color: rgba(255, 255, 0, 0.4);
}
.timepassedDot.breezy {
background-color: rgba(0, 255, 0, 0.4);
}
.timepassedDot.cold {
background-color: rgba(0, 0, 255, 0.1);
}
.kanbanCardTag {
font-size: 8pt;
border-radius: 4px;
padding: 2px;
}

View file

@ -0,0 +1,67 @@
import { observer } from 'mobx-react-lite';
import KanbanCardCss from './kanban-card.module.css';
import React from 'react';
import { ICardStore } from './store';
import { getStyleObjectFromString } from '../utils/style';
export type Props = {
store: ICardStore
};
export type TagProps = {
style?: string;
tag: string;
};
export const KanbanCardTag = (props: TagProps): JSX.Element => {
const inlineStyle = getStyleObjectFromString(props.style || '');
return (
<span className={KanbanCardCss.kanbanCardTag} style={inlineStyle}>
{props.tag}
</span>
);
}
/**
* Какие дальше требования к карточкам?
*
* 1. Отобразить как было в статичной доске
* 2. Переделать отображение с учётом store.params
*/
export const KanbanCard = observer((props: Props) => {
let timePassedColorClassName = '';
const timePassedIssueValue: string = props.store.issue.timePassedClass;
if (timePassedIssueValue && KanbanCardCss[timePassedIssueValue]) {
timePassedColorClassName = KanbanCardCss[timePassedIssueValue];
}
const timePassedClassName = `${KanbanCardCss.timepassedDot} ${timePassedColorClassName}`;
let tagsSection = <></>;
const tagsParams = props.store.params?.fields.find((field) => {
return field.component === 'tags';
});
if (tagsParams && props.store.issue[tagsParams.path]) {
const tags = props.store.issue[tagsParams.path] as TagProps[];
tagsSection = (
<div>
{tagsParams.label || 'Tags'}: {tags.map(tag => <KanbanCardTag tag={tag.tag} style={tag.style} />)}
</div>
);
}
return (
<div className={KanbanCardCss.kanbanCard}>
<div className={KanbanCardCss.kanbanCardTitle}>
<span className={timePassedClassName}></span>
<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.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>
</div>
);
});
export default KanbanCard;

View file

@ -0,0 +1,101 @@
import { Instance, types } from 'mobx-state-tree';
import { RedmineTypes } from '../redmine-types';
import axios from 'axios';
export const IssueStore = types.frozen<RedmineTypes.ExtendedIssue>()
export interface IIssueStore extends Instance<typeof IssueStore> {}
export const ColumnStore = types.model({
status: '',
count: 0,
issues: types.array(IssueStore)
}).views((self) => {
return {
get cards(): ICardStore[] {
return self.issues.map(issue => {
return {
issue: issue
} as ICardStore;
});
}
}
});
export interface IColumnStore extends Instance<typeof ColumnStore> {}
export const MetaInfoStore = types.model({
title: '',
url: types.maybe(types.string),
rootIssue: types.model({
id: 0,
tracker: types.model({
id: 0,
name: ''
}),
subject: ''
})
});
export interface IMetaInfoStore extends Instance<typeof MetaInfoStore> {}
export const BoardStore = types.model({
data: types.array(ColumnStore),
metainfo: MetaInfoStore
});
export interface IBoardStore extends Instance<typeof BoardStore> {}
export const PageStore = types.model({
loaded: false,
type: '',
name: '',
data: types.maybeNull(
types.array(BoardStore)
)
}).actions(self => {
return {
setData: (data: any) => {
self.data = data;
self.loaded = true;
}
};
});
export async function PageStoreLoadData(store: IPageStore): Promise<void> {
const url = `/simple-kanban-board/${store.type}/${store.name}/raw`;
const resp = await axios.get(url);
if (!(resp?.data)) return;
store.setData(resp.data);
}
export interface IPageStore extends Instance<typeof PageStore> { }
export type CardField = {
component: string;
} & Record<string, any>;
export const CardSettings = types.model({});
export const CardStore = types.model({
issue: IssueStore,
params: types.maybe(types.model({
fields: types.optional(
types.array(
types.frozen<CardField>()
),
[
{ 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: types.boolean
})),
});
export interface ICardStore extends Instance<typeof CardStore> {}

View file

@ -0,0 +1,140 @@
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
export module RedmineTypes {
export type IdAndName = {
id: number;
name: string;
};
export type CustomField = {
id: number;
name: string;
value: string;
};
export type JournalDetail = {
property: string;
name: string;
old_value?: string;
new_value?: string;
};
export type Journal = {
id: number;
user: IdAndName;
notes?: string;
created_on: string;
details?: JournalDetail[];
};
export type ChildIssue = {
id: number;
tracker: IdAndName;
subject: string;
children?: Children;
};
export type Children = ChildIssue[];
export type Issue = {
id: number;
project: IdAndName;
tracker: IdAndName;
status: IdAndName;
priority: IdAndName;
author: IdAndName;
assigned_to?: IdAndName;
category: IdAndName;
fixed_version?: IdAndName;
subject: string;
description: string;
start_date: string;
done_ratio: number;
spent_hours: number;
total_spent_hours: number;
custom_fields: CustomField[];
created_on: string;
updated_on?: string;
closed_on?: string;
relations?: Record<string, any>[];
journals?: Journal[];
children?: Children;
parent?: { id: number };
};
export type ExtendedIssue = Issue & Record<string, any>;
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace
export module Unknown {
export const num = -1;
export const str = '';
export const idAndName: IdAndName = {
id: -1,
name: str,
};
export const unknownName = 'Unknown';
export const subject = 'Unknown';
export const date = '1970-01-01T00:00:00Z';
export const issue: Issue = {
id: num,
project: idAndName,
tracker: idAndName,
status: idAndName,
priority: idAndName,
author: idAndName,
category: idAndName,
fixed_version: idAndName,
subject: subject,
description: str,
start_date: date,
done_ratio: num,
spent_hours: num,
total_spent_hours: num,
custom_fields: [],
created_on: date,
};
export const user: User = {
id: num,
login: str,
firstname: unknownName,
lastname: unknownName,
mail: str,
};
}
export type User = {
id: number;
login: string;
firstname: string;
lastname: string;
mail: string;
};
export type PublicUser = {
id: number;
firstname: string;
lastname: string;
login: string;
name: string;
};
export function CreatePublicUserFromUser(obj: User): PublicUser {
return {
id: obj.id,
login: obj.login,
firstname: obj.firstname,
lastname: obj.lastname,
name: `${obj.firstname} ${obj.lastname}`,
};
}
export function CreateUser(obj: User): User {
return {
id: obj.id,
login: obj.login,
firstname: obj.firstname,
lastname: obj.lastname,
mail: obj.mail,
};
}
}

View file

@ -2,12 +2,17 @@ import React from "react";
import { createBrowserRouter } from "react-router-dom";
import StartPage from "./start-page/start-page";
import UnknownPage from "./unknown-page";
import { KanbanBoardsPage } from "./kanban-board/kanban-boards-page";
export const router = createBrowserRouter([
{
path: "/",
element: (<StartPage/>),
},
{
path: "/kanban-board/:type/:name",
element: (<KanbanBoardsPage/>)
},
{
path: "*",
element: (<UnknownPage/>)

View file

@ -11,7 +11,7 @@
border: 0;
}
h1 {
.cover h1 {
left: 0%;
right: 0%;
top: 18.22%;
@ -23,7 +23,7 @@ h1 {
text-transform: uppercase;
}
h3 {
.cover h3 {
color: #F5B14E;
font-family: 'Source Code Pro';
letter-spacing: 0.12em;
@ -33,7 +33,7 @@ h3 {
line-height: 34px;
}
h4 {
.cover h4 {
font-style: normal;
font-weight: 600;
font-size: 40px;
@ -43,7 +43,7 @@ h4 {
font-feature-settings: 'pnum' on, 'lnum' on;
}
h4:hover {
.cover h4:hover {
text-decoration: underline;
color: #F5B14E;
}

View file

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