├── .env
├── .env.development
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── README.md
├── __json_server_mock__
├── db.json
└── middleware.js
├── craco.config.js
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
├── mockServiceWorker.js
└── robots.txt
├── src
├── App.css
├── App.test.tsx
├── App.tsx
├── assets
│ ├── bug.svg
│ ├── left.svg
│ ├── logo.svg
│ ├── right.svg
│ ├── software-logo.svg
│ └── task.svg
├── auth-provider.ts
├── authenticated-app.tsx
├── components
│ ├── error-boundary.tsx
│ ├── id-select.tsx
│ ├── lib.tsx
│ ├── pin.tsx
│ ├── project-popover.tsx
│ ├── task-type-select.tsx
│ └── user-select.tsx
├── context
│ ├── auth-context.tsx
│ └── index.tsx
├── hooks
│ ├── index.ts
│ ├── project.ts
│ ├── useAsync.ts
│ ├── useUndo.ts
│ └── user.ts
├── index.tsx
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
├── screens
│ ├── epic
│ │ └── index.tsx
│ ├── kanban
│ │ ├── index.tsx
│ │ ├── kanban-column.tsx
│ │ ├── search-pannel.tsx
│ │ └── utils.ts
│ ├── project-list
│ │ ├── index.tsx
│ │ ├── list.tsx
│ │ ├── project-modal.tsx
│ │ ├── search-pannel.tsx
│ │ └── utils.ts
│ └── project
│ │ └── index.tsx
├── setupTests.ts
├── types
│ ├── index.ts
│ ├── kanban.ts
│ ├── task-type.ts
│ └── task.ts
├── unauthenticated-app
│ ├── index.tsx
│ ├── login.tsx
│ └── register.tsx
├── utils
│ ├── http.ts
│ ├── index.ts
│ ├── kanban.ts
│ ├── task-type.ts
│ ├── task.ts
│ ├── url.ts
│ └── use-optimistic-options.ts
└── wdyr.ts
├── tsconfig.json
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_API_URL=http://online.com
2 | GENERATE_SOURCEMAP=false
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | REACT_APP_API_URL=http://localhost:3001
2 | GENERATE_SOURCEMAP=true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | #vscode
26 | /.vscode
27 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 | coverage
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `yarn start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `yarn test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `yarn build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `yarn eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/__json_server_mock__/db.json:
--------------------------------------------------------------------------------
1 | {
2 | "users": [
3 | {
4 | "id": 1,
5 | "name": "高修文"
6 | },
7 | {
8 | "id": 2,
9 | "name": "熊天成"
10 | },
11 | {
12 | "id": 3,
13 | "name": "郑华"
14 | },
15 | {
16 | "id": 4,
17 | "name": "王文静"
18 | }
19 | ],
20 | "projects": [
21 | {
22 | "id": 1,
23 | "name": "骑手管理",
24 | "personId": 1,
25 | "organization": "外卖组",
26 | "created": 1604989757139
27 | },
28 | {
29 | "id": 2,
30 | "name": "团购 APP",
31 | "personId": 2,
32 | "organization": "团购组",
33 | "created": 1604989757139
34 | },
35 | {
36 | "id": 3,
37 | "name": "物料管理系统",
38 | "personId": 2,
39 | "organization": "物料组",
40 | "created": 1546300800000
41 | },
42 | {
43 | "id": 4,
44 | "name": "总部管理系统",
45 | "personId": 3,
46 | "organization": "总部",
47 | "created": 1604980000011
48 | },
49 | {
50 | "id": 5,
51 | "name": "送餐路线规划系统",
52 | "personId": 4,
53 | "organization": "外卖组",
54 | "created": 1546900800000
55 | }
56 | ]
57 | }
--------------------------------------------------------------------------------
/__json_server_mock__/middleware.js:
--------------------------------------------------------------------------------
1 | module.exports = (req, res, next) => {
2 | if (req.method === "POST" && req.path === "/login") {
3 | if (req.body.username === "jack" && req.body.password === "123456") {
4 | return res.status(200).json({
5 | user: {
6 | token: "123",
7 | },
8 | });
9 | } else {
10 | return res.status(400).json({ message: "用户名或密码错误" });
11 | }
12 | }
13 | next();
14 | };
15 |
--------------------------------------------------------------------------------
/craco.config.js:
--------------------------------------------------------------------------------
1 | const CracoLessPlugin = require("craco-less");
2 |
3 | module.exports = {
4 | plugins: [
5 | {
6 | plugin: CracoLessPlugin,
7 | options: {
8 | lessLoaderOptions: {
9 | lessOptions: {
10 | modifyVars: {
11 | "@primary-color": "rgb(0, 82, 204)",
12 | "@font-size-based": "16px",
13 | },
14 | javascriptEnabled: true,
15 | },
16 | },
17 | },
18 | },
19 | ],
20 | };
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jira",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@craco/craco": "^6.1.1",
7 | "@emotion/react": "^11.1.5",
8 | "@emotion/styled": "^11.1.5",
9 | "@testing-library/jest-dom": "^5.11.4",
10 | "@testing-library/react": "^11.1.0",
11 | "@testing-library/user-event": "^12.1.10",
12 | "@types/jest": "^26.0.15",
13 | "@types/node": "^12.0.0",
14 | "@types/react": "^17.0.0",
15 | "@types/react-dom": "^17.0.0",
16 | "@welldone-software/why-did-you-render": "^6.0.5",
17 | "antd": "^4.12.3",
18 | "craco-less": "^1.17.1",
19 | "dayjs": "^1.10.4",
20 | "history": "^5.0.0",
21 | "jira-dev-tool": "^1.6.55",
22 | "qs": "^6.9.6",
23 | "react": "^17.0.1",
24 | "react-dom": "^17.0.1",
25 | "react-query": "^3.9.8",
26 | "react-router": "^6.0.0-beta.0",
27 | "react-router-dom": "^6.0.0-beta.0",
28 | "react-scripts": "4.0.2",
29 | "typescript": "^4.1.2",
30 | "web-vitals": "^1.0.1"
31 | },
32 | "scripts": {
33 | "start": "craco start",
34 | "build": "craco build",
35 | "test": "craco test",
36 | "eject": "react-scripts eject",
37 | "json-server": "json-server __json_server_mock__/db.json --watch --port 3001 --middlewares __json_server_mock__/middleware.js"
38 | },
39 | "eslintConfig": {
40 | "extends": [
41 | "react-app",
42 | "react-app/jest",
43 | "prettier"
44 | ]
45 | },
46 | "browserslist": {
47 | "production": [
48 | ">0.2%",
49 | "not dead",
50 | "not op_mini all"
51 | ],
52 | "development": [
53 | "last 1 chrome version",
54 | "last 1 firefox version",
55 | "last 1 safari version"
56 | ]
57 | },
58 | "devDependencies": {
59 | "@types/qs": "^6.9.5",
60 | "eslint-config-prettier": "^7.2.0",
61 | "husky": "^4.3.8",
62 | "json-server": "^0.16.3",
63 | "lint-staged": "^10.5.4",
64 | "prettier": "2.2.1"
65 | },
66 | "husky": {
67 | "hooks": {
68 | "pre-commit": "lint-staged"
69 | }
70 | },
71 | "lint-staged": {
72 | "*.{js,css,md,ts,tsx}": "prettier --write"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luowhsd/react_hooks_ts_jira/da02b89d6ade4d827cb2d5b7c78059cb8859bf94/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Jira任务管理系统
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luowhsd/react_hooks_ts_jira/da02b89d6ade4d827cb2d5b7c78059cb8859bf94/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/luowhsd/react_hooks_ts_jira/da02b89d6ade4d827cb2d5b7c78059cb8859bf94/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/mockServiceWorker.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Mock Service Worker.
3 | * @see https://github.com/mswjs/msw
4 | * - Please do NOT modify this file.
5 | * - Please do NOT serve this file on production.
6 | */
7 | /* eslint-disable */
8 | /* tslint:disable */
9 |
10 | const INTEGRITY_CHECKSUM = "dc3d39c97ba52ee7fff0d667f7bc098c";
11 | const bypassHeaderName = "x-msw-bypass";
12 |
13 | let clients = {};
14 |
15 | self.addEventListener("install", function () {
16 | return self.skipWaiting();
17 | });
18 |
19 | self.addEventListener("activate", async function (event) {
20 | return self.clients.claim();
21 | });
22 |
23 | self.addEventListener("message", async function (event) {
24 | const clientId = event.source.id;
25 |
26 | if (!clientId || !self.clients) {
27 | return;
28 | }
29 |
30 | const client = await self.clients.get(clientId);
31 |
32 | if (!client) {
33 | return;
34 | }
35 |
36 | const allClients = await self.clients.matchAll();
37 | const allClientIds = allClients.map((client) => client.id);
38 |
39 | switch (event.data) {
40 | case "KEEPALIVE_REQUEST": {
41 | sendToClient(client, {
42 | type: "KEEPALIVE_RESPONSE",
43 | });
44 | break;
45 | }
46 |
47 | case "INTEGRITY_CHECK_REQUEST": {
48 | sendToClient(client, {
49 | type: "INTEGRITY_CHECK_RESPONSE",
50 | payload: INTEGRITY_CHECKSUM,
51 | });
52 | break;
53 | }
54 |
55 | case "MOCK_ACTIVATE": {
56 | clients = ensureKeys(allClientIds, clients);
57 | clients[clientId] = true;
58 |
59 | sendToClient(client, {
60 | type: "MOCKING_ENABLED",
61 | payload: true,
62 | });
63 | break;
64 | }
65 |
66 | case "MOCK_DEACTIVATE": {
67 | clients = ensureKeys(allClientIds, clients);
68 | clients[clientId] = false;
69 | break;
70 | }
71 |
72 | case "CLIENT_CLOSED": {
73 | const remainingClients = allClients.filter((client) => {
74 | return client.id !== clientId;
75 | });
76 |
77 | // Unregister itself when there are no more clients
78 | if (remainingClients.length === 0) {
79 | self.registration.unregister();
80 | }
81 |
82 | break;
83 | }
84 | }
85 | });
86 |
87 | self.addEventListener("fetch", function (event) {
88 | const { clientId, request } = event;
89 | const requestId = uuidv4();
90 | const requestClone = request.clone();
91 | const getOriginalResponse = () => fetch(requestClone);
92 |
93 | // Bypass navigation requests.
94 | if (request.mode === "navigate") {
95 | return;
96 | }
97 |
98 | // Bypass mocking if the current client isn't present in the internal clients map
99 | // (i.e. has the mocking disabled).
100 | if (!clients[clientId]) {
101 | return;
102 | }
103 |
104 | // Opening the DevTools triggers the "only-if-cached" request
105 | // that cannot be handled by the worker. Bypass such requests.
106 | if (request.cache === "only-if-cached" && request.mode !== "same-origin") {
107 | return;
108 | }
109 |
110 | event.respondWith(
111 | new Promise(async (resolve, reject) => {
112 | const client = await self.clients.get(clientId);
113 |
114 | // Bypass mocking when the request client is not active.
115 | if (!client) {
116 | return resolve(getOriginalResponse());
117 | }
118 |
119 | // Bypass requests with the explicit bypass header
120 | if (requestClone.headers.get(bypassHeaderName) === "true") {
121 | const modifiedHeaders = serializeHeaders(requestClone.headers);
122 |
123 | // Remove the bypass header to comply with the CORS preflight check
124 | delete modifiedHeaders[bypassHeaderName];
125 |
126 | const originalRequest = new Request(requestClone, {
127 | headers: new Headers(modifiedHeaders),
128 | });
129 |
130 | return resolve(fetch(originalRequest));
131 | }
132 |
133 | const reqHeaders = serializeHeaders(request.headers);
134 | const body = await request.text();
135 |
136 | const rawClientMessage = await sendToClient(client, {
137 | type: "REQUEST",
138 | payload: {
139 | id: requestId,
140 | url: request.url,
141 | method: request.method,
142 | headers: reqHeaders,
143 | cache: request.cache,
144 | mode: request.mode,
145 | credentials: request.credentials,
146 | destination: request.destination,
147 | integrity: request.integrity,
148 | redirect: request.redirect,
149 | referrer: request.referrer,
150 | referrerPolicy: request.referrerPolicy,
151 | body,
152 | bodyUsed: request.bodyUsed,
153 | keepalive: request.keepalive,
154 | },
155 | });
156 |
157 | const clientMessage = rawClientMessage;
158 |
159 | switch (clientMessage.type) {
160 | case "MOCK_SUCCESS": {
161 | setTimeout(
162 | resolve.bind(this, createResponse(clientMessage)),
163 | clientMessage.payload.delay
164 | );
165 | break;
166 | }
167 |
168 | case "MOCK_NOT_FOUND": {
169 | return resolve(getOriginalResponse());
170 | }
171 |
172 | case "NETWORK_ERROR": {
173 | const { name, message } = clientMessage.payload;
174 | const networkError = new Error(message);
175 | networkError.name = name;
176 |
177 | // Rejecting a request Promise emulates a network error.
178 | return reject(networkError);
179 | }
180 |
181 | case "INTERNAL_ERROR": {
182 | const parsedBody = JSON.parse(clientMessage.payload.body);
183 |
184 | console.error(
185 | `\
186 | [MSW] Request handler function for "%s %s" has thrown the following exception:
187 |
188 | ${parsedBody.errorType}: ${parsedBody.message}
189 | (see more detailed error stack trace in the mocked response body)
190 |
191 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error.
192 | If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
193 | `,
194 | request.method,
195 | request.url
196 | );
197 |
198 | return resolve(createResponse(clientMessage));
199 | }
200 | }
201 | })
202 | .then(async (response) => {
203 | const client = await self.clients.get(clientId);
204 | const clonedResponse = response.clone();
205 |
206 | sendToClient(client, {
207 | type: "RESPONSE",
208 | payload: {
209 | requestId,
210 | type: clonedResponse.type,
211 | ok: clonedResponse.ok,
212 | status: clonedResponse.status,
213 | statusText: clonedResponse.statusText,
214 | body:
215 | clonedResponse.body === null ? null : await clonedResponse.text(),
216 | headers: serializeHeaders(clonedResponse.headers),
217 | redirected: clonedResponse.redirected,
218 | },
219 | });
220 |
221 | return response;
222 | })
223 | .catch((error) => {
224 | console.error(
225 | '[MSW] Failed to mock a "%s" request to "%s": %s',
226 | request.method,
227 | request.url,
228 | error
229 | );
230 | })
231 | );
232 | });
233 |
234 | function serializeHeaders(headers) {
235 | const reqHeaders = {};
236 | headers.forEach((value, name) => {
237 | reqHeaders[name] = reqHeaders[name]
238 | ? [].concat(reqHeaders[name]).concat(value)
239 | : value;
240 | });
241 | return reqHeaders;
242 | }
243 |
244 | function sendToClient(client, message) {
245 | return new Promise((resolve, reject) => {
246 | const channel = new MessageChannel();
247 |
248 | channel.port1.onmessage = (event) => {
249 | if (event.data && event.data.error) {
250 | reject(event.data.error);
251 | } else {
252 | resolve(event.data);
253 | }
254 | };
255 |
256 | client.postMessage(JSON.stringify(message), [channel.port2]);
257 | });
258 | }
259 |
260 | function createResponse(clientMessage) {
261 | return new Response(clientMessage.payload.body, {
262 | ...clientMessage.payload,
263 | headers: clientMessage.payload.headers,
264 | });
265 | }
266 |
267 | function ensureKeys(keys, obj) {
268 | return Object.keys(obj).reduce((acc, key) => {
269 | if (keys.includes(key)) {
270 | acc[key] = obj[key];
271 | }
272 |
273 | return acc;
274 | }, {});
275 | }
276 |
277 | function uuidv4() {
278 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
279 | const r = (Math.random() * 16) | 0;
280 | const v = c == "x" ? r : (r & 0x3) | 0x8;
281 | return v.toString(16);
282 | });
283 | }
284 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | html {
2 | /* 16px * 62.5% = 10px */
3 | /* 1rem === 10px */
4 | font-size: 62.5%;
5 | }
6 |
7 | html body #root .app {
8 | min-height: 100vh;
9 | }
10 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | render();
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { UnauthenticatedApp } from "unauthenticated-app";
2 | import { AuthenticatedApp } from "authenticated-app";
3 | import { useAuth } from "context/auth-context";
4 | import "./App.css";
5 | import { ErroryBoundary } from "components/error-boundary";
6 | import { FullPageErrorFallback } from "components/lib";
7 |
8 | function App() {
9 | const { user } = useAuth();
10 | return (
11 |
12 |
13 | {user ? : }
14 |
15 |
16 | );
17 | }
18 |
19 | export default App;
20 |
--------------------------------------------------------------------------------
/src/assets/bug.svg:
--------------------------------------------------------------------------------
1 |
2 |
16 |
--------------------------------------------------------------------------------
/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/src/assets/right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
1000 |
--------------------------------------------------------------------------------
/src/assets/software-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/task.svg:
--------------------------------------------------------------------------------
1 |
2 |
20 |
--------------------------------------------------------------------------------
/src/auth-provider.ts:
--------------------------------------------------------------------------------
1 | // 在真实环境中,如果使用firebase这种第三方auth服务的话,本文件不需要开发者开发
2 | import { User } from "screens/project-list/search-pannel";
3 |
4 | const localStorageKey = "__auth_provider_token__";
5 |
6 | const apiUrl = process.env.REACT_APP_API_URL;
7 |
8 | export const getToken = () => window.localStorage.getItem(localStorageKey);
9 |
10 | export const handleUserResponse = ({ user }: { user: User }) => {
11 | window.localStorage.setItem(localStorageKey, user.token || "");
12 | return user;
13 | };
14 |
15 | export const login = (data: { username: string; password: string }) => {
16 | return fetch(`${apiUrl}/login`, {
17 | method: "POST",
18 | headers: {
19 | "Content-type": "application/json",
20 | },
21 | body: JSON.stringify(data),
22 | }).then(async (response) => {
23 | if (response.ok) {
24 | return handleUserResponse(await response.json());
25 | } else {
26 | return Promise.reject(await response.json());
27 | }
28 | });
29 | };
30 |
31 | export const register = (data: { username: string; password: string }) => {
32 | return fetch(`${apiUrl}/register`, {
33 | method: "POST",
34 | headers: {
35 | "Content-type": "application/json",
36 | },
37 | body: JSON.stringify(data),
38 | }).then(async (response) => {
39 | if (response.ok) {
40 | return handleUserResponse(await response.json());
41 | } else {
42 | return Promise.reject(await response.json());
43 | }
44 | });
45 | };
46 |
47 | export const logout = async () =>
48 | window.localStorage.removeItem(localStorageKey);
49 |
--------------------------------------------------------------------------------
/src/authenticated-app.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { ButtonNoPadding, Row } from "components/lib";
3 | import { useAuth } from "context/auth-context";
4 | import { ProjectListScreen } from "screens/project-list";
5 | import { ReactComponent as SoftWareLogo } from "assets/software-logo.svg";
6 | import { Button, Dropdown, Menu } from "antd";
7 | import { Navigate, Route, Routes } from "react-router";
8 | import { BrowserRouter as Router } from "react-router-dom";
9 | import { ProjectScreen } from "screens/project";
10 | import { resetRoute } from "utils";
11 | import { ProjectModal } from "screens/project-list/project-modal";
12 | import { ProjectPopover } from "components/project-popover";
13 |
14 | export const AuthenticatedApp = () => {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | }>
22 | }
25 | >
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | const PageHeader = () => {
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 | 用户
44 |
45 |
46 |
47 |
48 |
49 | );
50 | };
51 |
52 | const User = () => {
53 | const { logout, user } = useAuth();
54 | return (
55 |
58 |
59 |
62 |
63 |
64 | }
65 | >
66 |
67 |
68 | );
69 | };
70 |
71 | const Container = styled.div`
72 | display: grid;
73 | grid-template-rows: 6rem 1fr;
74 | height: 100vh;
75 | `;
76 |
77 | const Header = styled(Row)`
78 | justify-content: space-between;
79 | padding: 3.2rem;
80 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
81 | `;
82 |
83 | const HeaderLeft = styled(Row)``;
84 |
85 | const HeaderRight = styled.div``;
86 |
87 | const Main = styled.main`
88 | /* height: calc(100vh - 6rem); */
89 | display: flex;
90 | overflow: hidden;
91 | `;
92 |
--------------------------------------------------------------------------------
/src/components/error-boundary.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 |
3 | type Props = React.PropsWithChildren<{ fallbackRender: FallbackRender }>;
4 | type FallbackRender = (props: { error: Error | null }) => React.ReactElement;
5 |
6 | export class ErroryBoundary extends Component {
7 | state = { error: null };
8 | // 当子组件抛出异常,这里会接收到并且调用
9 | static getDerivedStateFromError(error: Error) {
10 | return { error };
11 | }
12 | render() {
13 | const { error } = this.state;
14 | const { fallbackRender, children } = this.props;
15 | if (error) {
16 | return fallbackRender({ error });
17 | }
18 | return children;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/id-select.tsx:
--------------------------------------------------------------------------------
1 | import { Select } from "antd";
2 | import { Raw } from "types";
3 |
4 | type SelectProps = React.ComponentProps;
5 |
6 | interface IdSelectProps
7 | extends Omit {
8 | value?: Raw | null | undefined;
9 | onChange?: (value?: number) => void;
10 | defaultOptionName?: string;
11 | options?: { name: string; id: number }[];
12 | }
13 |
14 | /**
15 | * value 可以传入多种类型的值
16 | * onChange只会回调number|undefined类型
17 | * 当isNaN(Number(value))为true时,代表选择默认类型
18 | * 当选择默认类型的时候,onChange会回调undefined
19 | * @param props
20 | */
21 | export const IdSelect = (props: IdSelectProps) => {
22 | const { value, onChange, defaultOptionName, options, ...restProps } = props;
23 | return (
24 |
38 | );
39 | };
40 |
41 | const toNumber = (value: unknown) => (isNaN(Number(value)) ? 0 : Number(value));
42 |
--------------------------------------------------------------------------------
/src/components/lib.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { Spin, Typography, Button } from "antd";
3 | import { DevTools } from "jira-dev-tool";
4 |
5 | export const Row = styled.div<{
6 | gap?: number | boolean;
7 | between?: boolean;
8 | marginBottom?: number;
9 | }>`
10 | display: flex;
11 | align-items: center;
12 | justify-content: ${(props) => (props.between ? "space-between" : undefined)};
13 | > * {
14 | margin-top: 0 !important;
15 | margin-bottom: 0 !important;
16 | margin-right: ${(props) =>
17 | typeof props.gap === "number"
18 | ? props.gap + "rem"
19 | : props.gap
20 | ? "2rem"
21 | : undefined};
22 | }
23 | `;
24 |
25 | const FullPage = styled.div`
26 | height: 100vh;
27 | display: flex;
28 | justify-content: center;
29 | align-items: center;
30 | `;
31 |
32 | export const FullPageLoading = () => {
33 | return (
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export const FullPageErrorFallback = ({ error }: { error: Error | null }) => (
41 |
42 |
43 |
44 |
45 | );
46 |
47 | export const ButtonNoPadding = styled(Button)`
48 | padding: 0;
49 | `;
50 |
51 | // 类型守卫
52 | const isError = (value: any): value is Error => value?.message;
53 |
54 | export const ErrorBox = ({ error }: { error: unknown }) => {
55 | if (isError(error)) {
56 | return {error?.message};
57 | }
58 | return null;
59 | };
60 |
61 | export const ScreenContainer = styled.div`
62 | padding: 3.2rem;
63 | width: 100%;
64 | display: flex;
65 | flex-direction: column;
66 | `;
67 |
--------------------------------------------------------------------------------
/src/components/pin.tsx:
--------------------------------------------------------------------------------
1 | import { Rate } from "antd";
2 | import React from "react";
3 |
4 | interface PinProps extends React.ComponentProps {
5 | checked: boolean;
6 | onCheckedChange?: (checked: boolean) => void;
7 | }
8 |
9 | export const Pin = ({ checked, onCheckedChange, ...restProps }: PinProps) => {
10 | return (
11 | onCheckedChange?.(!!num)}
15 | {...restProps}
16 | />
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/project-popover.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { Divider, List, Popover, Typography } from "antd";
3 | import { useProjects } from "hooks/project";
4 | import { ButtonNoPadding } from "components/lib";
5 | import { useProjectModal } from "screens/project-list/utils";
6 |
7 | export const ProjectPopover = () => {
8 | const { data: projects } = useProjects();
9 | const { open } = useProjectModal();
10 | const pinnedProjects = projects?.filter((project) => project.pin);
11 | const content = (
12 |
13 | 收藏项目
14 |
15 | {pinnedProjects?.map((project) => (
16 |
17 |
18 |
19 | ))}
20 |
21 |
22 |
23 | 创建项目
24 |
25 |
26 | );
27 | return (
28 |
29 | 项目
30 |
31 | );
32 | };
33 |
34 | const ContentContainer = styled.div`
35 | min-width: 30rem;
36 | `;
37 |
--------------------------------------------------------------------------------
/src/components/task-type-select.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { IdSelect } from "components/id-select";
3 | import { useTaskTypes } from "utils/task-type";
4 |
5 | export const TaskTypeSelect = (
6 | props: React.ComponentProps
7 | ) => {
8 | const { data: taskTypes } = useTaskTypes();
9 | return ;
10 | };
11 |
--------------------------------------------------------------------------------
/src/components/user-select.tsx:
--------------------------------------------------------------------------------
1 | import { useUser } from "hooks/user";
2 | import { IdSelect } from "./id-select";
3 |
4 | export const UserSelect = (props: React.ComponentProps) => {
5 | const { data: users } = useUser();
6 | return ;
7 | };
8 |
--------------------------------------------------------------------------------
/src/context/auth-context.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from "react";
2 | import * as auth from "auth-provider";
3 | import { User } from "screens/project-list/search-pannel";
4 | import { http } from "utils/http";
5 | import { useMount } from "hooks";
6 | import { useAsync } from "hooks/useAsync";
7 | import { FullPageErrorFallback, FullPageLoading } from "components/lib";
8 | import { useQueryClient } from "react-query";
9 |
10 | interface AuthForm {
11 | username: string;
12 | password: string;
13 | }
14 |
15 | type InitContext = {
16 | user: User | null;
17 | login: (form: AuthForm) => Promise;
18 | register: (form: AuthForm) => Promise;
19 | logout: () => Promise;
20 | };
21 |
22 | const bootstrapUser = async () => {
23 | let user = null;
24 | const token = auth.getToken();
25 | if (token) {
26 | const data = await http("me", { token });
27 | user = data.user;
28 | }
29 | return user;
30 | };
31 |
32 | const AuthContext = React.createContext(undefined);
33 | AuthContext.displayName = "AuthContext";
34 |
35 | export const AuthProvider = ({ children }: { children: ReactNode }) => {
36 | const {
37 | data: user,
38 | error,
39 | isLoading,
40 | isIdle,
41 | isError,
42 | run,
43 | setData: setUser,
44 | } = useAsync();
45 | const queryClient = useQueryClient();
46 |
47 | const login = (form: AuthForm) => auth.login(form).then(setUser);
48 | const register = (form: AuthForm) => auth.register(form).then(setUser);
49 | const logout = () =>
50 | auth.logout().then(() => {
51 | setUser(null);
52 | queryClient.clear();
53 | });
54 | useMount(() => {
55 | run(bootstrapUser());
56 | });
57 |
58 | if (isIdle || isLoading) {
59 | return ;
60 | }
61 |
62 | if (isError) {
63 | return ;
64 | }
65 |
66 | return (
67 |
71 | );
72 | };
73 |
74 | export const useAuth = () => {
75 | const context = React.useContext(AuthContext);
76 | if (!context) {
77 | throw new Error("useAuth必须在AuthProvider中使用");
78 | }
79 | return context;
80 | };
81 |
--------------------------------------------------------------------------------
/src/context/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { QueryClient, QueryClientProvider } from "react-query";
3 | import { AuthProvider } from "./auth-context";
4 | export const AppProviders = ({ children }: { children: ReactNode }) => {
5 | return (
6 |
7 | {children}
8 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef } from "react";
2 |
3 | export const useMount = (callback: () => void) => {
4 | useEffect(() => {
5 | callback();
6 | // todo 依赖项里加上callback会造成无限循环, 这个和useCallback以及useMemo有关
7 | // eslint-disable-next-line react-hooks/exhaustive-deps
8 | }, []);
9 | };
10 |
11 | export const useDebounce = (value: T, delay?: number) => {
12 | const [debounceValue, setDebounceValue] = useState(value);
13 | useEffect(() => {
14 | const timeout = setTimeout(() => {
15 | setDebounceValue(value);
16 | }, delay);
17 | return () => clearTimeout(timeout);
18 | }, [value, delay]);
19 | return debounceValue;
20 | };
21 |
22 | export const useArray = (arr: T[]) => {
23 | const [arrTemp, setArrTemp] = useState(arr);
24 |
25 | const clear = () => {
26 | setArrTemp([]);
27 | };
28 |
29 | const removeIndex = (idx: number) => {
30 | const temp = [...arrTemp];
31 | const newArr = temp.splice(idx, 1);
32 | setArrTemp(newArr);
33 | };
34 |
35 | const add = (value: T) => {
36 | setArrTemp([...arrTemp, value]);
37 | };
38 |
39 | return {
40 | arrTemp,
41 | clear,
42 | removeIndex,
43 | add,
44 | };
45 | };
46 |
47 | export const useDocumentTitle = (
48 | title: string,
49 | keepOnUmount: boolean = true
50 | ) => {
51 | const oldTitle = useRef(document.title).current;
52 | useEffect(() => {
53 | document.title = title;
54 | }, [title]);
55 | useEffect(() => {
56 | return () => {
57 | if (!keepOnUmount) {
58 | // 不加依赖项读到的oldTitle是最初的
59 | document.title = oldTitle;
60 | }
61 | };
62 | }, [keepOnUmount, oldTitle]);
63 | };
64 |
65 | /**
66 | * 返回组件的挂载状态,如果还没挂载或者已经卸载,返回false,反之返回true
67 | */
68 | export const useMountedRef = () => {
69 | const mountedRef = useRef(false);
70 | useEffect(() => {
71 | mountedRef.current = true;
72 | return () => {
73 | mountedRef.current = false;
74 | };
75 | });
76 | return mountedRef;
77 | };
78 |
--------------------------------------------------------------------------------
/src/hooks/project.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "react-query";
2 | import { Project } from "screens/project-list/list";
3 | import { useHttp } from "utils/http";
4 |
5 | export const useProjects = (params?: Partial) => {
6 | const client = useHttp();
7 | return useQuery(["projects", params], () =>
8 | client("projects", { data: params })
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/hooks/useAsync.ts:
--------------------------------------------------------------------------------
1 | import { useMountedRef } from "hooks";
2 | import { useCallback, useReducer, useState } from "react";
3 | interface State {
4 | error: null | Error;
5 | data: D | null;
6 | stat: "idle" | "loading" | "error" | "success";
7 | }
8 |
9 | const defaultInitialState: State = {
10 | stat: "idle",
11 | data: null,
12 | error: null,
13 | };
14 |
15 | const defaultInitConfig = {
16 | throwOnError: false,
17 | };
18 |
19 | const useSafeDispatch = (dispatch: (...args: T[]) => void) => {
20 | const mountedRef = useMountedRef();
21 | return useCallback(
22 | (...args: T[]) => (mountedRef.current ? dispatch(...args) : void 0),
23 | [dispatch, mountedRef]
24 | );
25 | };
26 |
27 | export const useAsync = (
28 | initialState?: State,
29 | initialConifg?: typeof defaultInitConfig
30 | ) => {
31 | const config = { ...defaultInitConfig, ...initialConifg };
32 | const [state, dispatch] = useReducer(
33 | (state: State, action: Partial>) => ({
34 | ...state,
35 | ...action,
36 | }),
37 | { ...defaultInitialState, ...initialState }
38 | );
39 |
40 | const safeDispatch = useSafeDispatch(dispatch);
41 | const [retry, setRetry] = useState(() => () => {});
42 |
43 | const setData = useCallback(
44 | (data: D) =>
45 | safeDispatch({
46 | stat: "success",
47 | data,
48 | error: null,
49 | }),
50 | [safeDispatch]
51 | );
52 |
53 | const setError = useCallback(
54 | (error: Error) =>
55 | safeDispatch({
56 | error,
57 | stat: "error",
58 | data: null,
59 | }),
60 | [safeDispatch]
61 | );
62 |
63 | // 用来触发异步请求
64 | const run = useCallback(
65 | (promise: Promise, runConfig?: { retry: () => Promise }) => {
66 | if (!promise || !promise.then) {
67 | throw new Error("请传入 promise 类型数组");
68 | }
69 | safeDispatch({ stat: "loading" });
70 |
71 | setRetry(() => () => {
72 | if (runConfig?.retry) {
73 | run(runConfig?.retry(), runConfig);
74 | }
75 | });
76 | return promise
77 | .then((data) => {
78 | setData(data);
79 | return data;
80 | })
81 | .catch((error) => {
82 | // catch 会消化异常导致不再抛出
83 | setError(error);
84 | if (config.throwOnError) return Promise.reject(error);
85 | return error;
86 | });
87 | },
88 | [config.throwOnError, setData, setError, safeDispatch]
89 | );
90 |
91 | return {
92 | isIdle: state.stat === "idle",
93 | isLoading: state.stat === "loading",
94 | isError: state.stat === "error",
95 | isSuccess: state.stat === "success",
96 | retry,
97 | run,
98 | setData,
99 | setError,
100 | ...state,
101 | };
102 | };
103 |
--------------------------------------------------------------------------------
/src/hooks/useUndo.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useReducer } from "react";
2 |
3 | const UNDO = "undo";
4 | const REDO = "redo";
5 | const SET = "SET";
6 | const RESET = "reset";
7 |
8 | type State = {
9 | past: T[];
10 | present: T;
11 | future: T[];
12 | };
13 |
14 | type Action = {
15 | newPresent?: T;
16 | type: typeof UNDO | typeof REDO | typeof SET | typeof RESET;
17 | };
18 |
19 | const undoReducer = (state: State, action: Action) => {
20 | const { past, present, future } = state;
21 | const { type, newPresent } = action;
22 | switch (type) {
23 | case UNDO: {
24 | if (past.length === 0) return state;
25 | const previous = past[past.length - 1];
26 | const newPast = past.slice(0, past.length - 1);
27 | return {
28 | past: newPast,
29 | present: previous,
30 | future: [present, ...future],
31 | };
32 | }
33 | case REDO: {
34 | if (future.length === 0) return state;
35 | const next = future[0];
36 | const newFuture = future.slice(1);
37 | return {
38 | past: [...past, present],
39 | present: next,
40 | future: newFuture,
41 | };
42 | }
43 | case SET: {
44 | if (newPresent === present) return state;
45 | return {
46 | past: [...past, newPresent],
47 | present: newPresent,
48 | future: [],
49 | };
50 | }
51 | case RESET: {
52 | return {
53 | past: [],
54 | present: newPresent,
55 | future: [],
56 | };
57 | }
58 | default:
59 | }
60 | return state;
61 | };
62 |
63 | export const useUndo = (initialPresent: T) => {
64 | const [state, dispatch] = useReducer(undoReducer, {
65 | past: [],
66 | present: initialPresent,
67 | future: [],
68 | } as State);
69 |
70 | const canUndo = state.past.length !== 0;
71 | const canRedo = state.future.length !== 0;
72 |
73 | const undo = useCallback(() => dispatch({ type: UNDO }), []);
74 |
75 | const redo = useCallback(() => dispatch({ type: REDO }), []);
76 |
77 | const set = useCallback(
78 | (newPresent: T) => dispatch({ type: SET, newPresent }),
79 | []
80 | );
81 |
82 | const reset = useCallback(
83 | (newPresent: T) => dispatch({ type: RESET, newPresent }),
84 | []
85 | );
86 |
87 | return [state, { set, reset, redo, undo, canRedo, canUndo }] as const;
88 | };
89 |
--------------------------------------------------------------------------------
/src/hooks/user.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { User } from "screens/project-list/search-pannel";
3 | import { cleanObject } from "utils";
4 | import { useHttp } from "utils/http";
5 | import { useAsync } from "./useAsync";
6 |
7 | export const useUser = (params?: Partial) => {
8 | const client = useHttp();
9 | const { run, ...result } = useAsync();
10 | useEffect(() => {
11 | run(client("users", { data: cleanObject(params || {}) }));
12 | // eslint-disable-next-line react-hooks/exhaustive-deps
13 | }, [params]);
14 | return result;
15 | };
16 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import "./wdyr";
2 | import React from "react";
3 | import ReactDOM from "react-dom";
4 | import App from "./App";
5 | import reportWebVitals from "./reportWebVitals";
6 | import { DevTools, loadServer } from "jira-dev-tool";
7 | // 务必在jira-dev-tool后面引入
8 | import "antd/dist/antd.less";
9 | import { AppProviders } from "context";
10 |
11 | loadServer(() =>
12 | ReactDOM.render(
13 |
14 |
15 |
16 |
17 |
18 | ,
19 | document.getElementById("root")
20 | )
21 | );
22 |
23 | // If you want to start measuring performance in your app, pass a function
24 | // to log results (for example: reportWebVitals(console.log))
25 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
26 | reportWebVitals();
27 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/screens/epic/index.tsx:
--------------------------------------------------------------------------------
1 | export const EpicScreen = () => {
2 | return EpicScreen
;
3 | };
4 |
--------------------------------------------------------------------------------
/src/screens/kanban/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { ScreenContainer } from "components/lib";
3 | import { useDocumentTitle } from "hooks";
4 | import { useKanbans } from "utils/kanban";
5 | import { KanbanColumn } from "./kanban-column";
6 | import { SearchPanel } from "./search-pannel";
7 | import { useKanbanSearchParams, useProjectInUrl } from "./utils";
8 |
9 | export const KanbanScreen = () => {
10 | useDocumentTitle("看板列表");
11 | const { data: currProject } = useProjectInUrl();
12 | const { data: kanbans } = useKanbans(useKanbanSearchParams());
13 | return (
14 |
15 | {currProject?.name}看板
16 |
17 |
18 | {kanbans?.map((kanban) => (
19 |
20 | ))}
21 |
22 |
23 | );
24 | };
25 |
26 | const ColumnsContainer = styled.div`
27 | display: flex;
28 | overflow: scroll-x;
29 | flex: 1;
30 | `;
31 |
--------------------------------------------------------------------------------
/src/screens/kanban/kanban-column.tsx:
--------------------------------------------------------------------------------
1 | import { Kanban } from "types/kanban";
2 | import { useTasks } from "utils/task";
3 | import { useTasksSearchParams } from "./utils";
4 | import taskIcon from "assets/task.svg";
5 | import bugIcon from "assets/bug.svg";
6 | import { useTaskTypes } from "utils/task-type";
7 | import styled from "@emotion/styled";
8 | import { Card } from "antd";
9 |
10 | const TaskTypeIcon = ({ id }: { id: number }) => {
11 | const { data: taskTypes } = useTaskTypes();
12 | const name = taskTypes?.find((taskType) => taskType.id === id)?.name;
13 | if (!name) {
14 | return null;
15 | }
16 | return
;
17 | };
18 |
19 | export const KanbanColumn = ({ kanban }: { kanban: Kanban }) => {
20 | const { data: allTasks } = useTasks(useTasksSearchParams());
21 | const tasks = allTasks?.filter((task) => task.kanbanId === kanban.id);
22 | return (
23 |
24 | {kanban.name}
25 |
26 | {tasks?.map((task) => (
27 |
28 | {task.name}
29 |
30 |
31 | ))}
32 |
33 |
34 | );
35 | };
36 |
37 | export const Container = styled.div`
38 | min-width: 27rem;
39 | border-radius: 6px;
40 | background-color: rgb(244, 245, 247);
41 | display: flex;
42 | flex-direction: column;
43 | padding: 0.7rem 0.7rem 1rem;
44 | margin-right: 1.5rem;
45 | `;
46 |
47 | const TasksContainer = styled.div`
48 | overflow: scroll;
49 | flex: 1;
50 | ::-webkit-scrollbar {
51 | display: none;
52 | }
53 | `;
54 |
--------------------------------------------------------------------------------
/src/screens/kanban/search-pannel.tsx:
--------------------------------------------------------------------------------
1 | import { useSetUrlSearchParam } from "utils/url";
2 | import { Row, ScreenContainer } from "components/lib";
3 | import { Button, Input } from "antd";
4 | import { UserSelect } from "components/user-select";
5 | import { useTasksSearchParams } from "./utils";
6 | import { TaskTypeSelect } from "components/task-type-select";
7 |
8 | export const SearchPanel = () => {
9 | const searchParams = useTasksSearchParams();
10 | const setSearchParams = useSetUrlSearchParam();
11 | const reset = () => {
12 | setSearchParams({
13 | typeId: undefined,
14 | processorId: undefined,
15 | tagId: undefined,
16 | name: undefined,
17 | });
18 | };
19 |
20 | return (
21 |
22 | setSearchParams({ name: evt.target.value })}
27 | />
28 | setSearchParams({ processorId: value })}
32 | />
33 | setSearchParams({ typeId: value })}
37 | />
38 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/screens/kanban/utils.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { useLocation } from "react-router";
3 | import { useProject } from "screens/project-list/utils";
4 | import { useUrlQueryParam } from "utils/url";
5 |
6 | export const useProjectIdInUrl = () => {
7 | const { pathname } = useLocation();
8 | const id = pathname.match(/projects\/(\d+)/)?.[1];
9 | return Number(id);
10 | };
11 |
12 | export const useProjectInUrl = () => useProject(useProjectIdInUrl());
13 |
14 | export const useKanbanSearchParams = () => ({ projectId: useProjectIdInUrl() });
15 |
16 | export const useKanbanQueryKey = () => ["kanbans", useKanbanSearchParams()];
17 |
18 | export const useTasksSearchParams = () => {
19 | const [param] = useUrlQueryParam(["name", "typeId", "processorId", "tagId"]);
20 | const projectId = useProjectIdInUrl();
21 | return useMemo(
22 | () => ({
23 | projectId,
24 | typeId: Number(param.typeId) || undefined,
25 | processorId: Number(param.processorId) || undefined,
26 | tagId: Number(param.tagId) || undefined,
27 | name: param.name,
28 | }),
29 | [projectId, param]
30 | );
31 | };
32 |
33 | export const useTasksQueryKey = () => ["tasks", useTasksSearchParams()];
34 |
--------------------------------------------------------------------------------
/src/screens/project-list/index.tsx:
--------------------------------------------------------------------------------
1 | import { List } from "./list";
2 | import { SearchPannel } from "./search-pannel";
3 | import { useDebounce, useDocumentTitle } from "hooks";
4 | import styled from "@emotion/styled";
5 | import { Row } from "antd";
6 | import { useProjects } from "hooks/project";
7 | import { useUser } from "hooks/user";
8 | import { useProjectModal, useProjectSearchParam } from "./utils";
9 | import { ButtonNoPadding, ErrorBox } from "components/lib";
10 |
11 | export const ProjectListScreen = () => {
12 | useDocumentTitle("项目列表", false);
13 | const [param, setParam] = useProjectSearchParam();
14 | const { isLoading, data: list, error } = useProjects(useDebounce(param, 200));
15 | const { data: users } = useUser();
16 | const { open } = useProjectModal();
17 | return (
18 |
19 |
20 | 项目列表
21 |
22 | 创建项目
23 |
24 |
25 |
30 |
31 |
36 |
37 | );
38 | };
39 |
40 | ProjectListScreen.whyDidYouRender = false;
41 |
42 | const Container = styled.div`
43 | padding: 3.2rem;
44 | `;
45 |
--------------------------------------------------------------------------------
/src/screens/project-list/list.tsx:
--------------------------------------------------------------------------------
1 | import { Dropdown, Menu, Table, TableProps, Modal } from "antd";
2 | import dayjs from "dayjs";
3 | import { User } from "screens/project-list/search-pannel";
4 | // react-router 和 react-router-dom的关系就和 react 与 react-dom react-native react-vr...
5 | // react是核心库,计算的结果给react-dom消费
6 | // react-router也是一样
7 | import { Link } from "react-router-dom";
8 | import { Pin } from "components/pin";
9 | import {
10 | useDeleteProject,
11 | useEditProject,
12 | useProjectModal,
13 | useProjectQueryKey,
14 | } from "./utils";
15 | import { ButtonNoPadding } from "components/lib";
16 |
17 | export interface Project {
18 | id: number;
19 | name: string;
20 | personId: number;
21 | pin: boolean;
22 | organization: string;
23 | created: number;
24 | }
25 |
26 | interface ListProps extends TableProps {
27 | users: User[];
28 | }
29 |
30 | export const List = ({ users, ...props }: ListProps) => {
31 | const { mutate } = useEditProject(useProjectQueryKey());
32 | const pinProject = (id: number) => (pin: boolean) => mutate({ id, pin });
33 | return (
34 | ,
39 | render: (value, project) => {
40 | return (
41 |
45 | );
46 | },
47 | },
48 | {
49 | title: "名称",
50 | dataIndex: "name",
51 | sorter: (a, b) => a.name.localeCompare(b.name),
52 | render: (value, project) => {
53 | return {project.name};
54 | },
55 | },
56 | {
57 | title: "部门",
58 | dataIndex: "organization",
59 | },
60 | {
61 | title: "负责人",
62 | render: (value, project) => {
63 | return (
64 |
65 | {users.find((user) => user.id === project.personId)?.name ||
66 | "未知"}
67 |
68 | );
69 | },
70 | },
71 | {
72 | title: "创建时间",
73 | dataIndex: "created",
74 | render: (value, project) => {
75 | return (
76 |
77 | {project.created
78 | ? dayjs(project.created).format("YYYY-MM-DD")
79 | : "无"}
80 |
81 | );
82 | },
83 | },
84 | {
85 | render(value, project) {
86 | return ;
87 | },
88 | },
89 | ]}
90 | {...props}
91 | />
92 | );
93 | };
94 |
95 | const More = ({ project }: { project: Project }) => {
96 | const { startEdit } = useProjectModal();
97 | const editProject = (id: number) => () => startEdit(id);
98 | const { mutate: deleteProject } = useDeleteProject(useProjectQueryKey());
99 | const confirmDeleteProject = (id: number) => {
100 | Modal.confirm({
101 | title: "确定删除这个项目吗",
102 | content: "点击确定删除",
103 | okText: "确定",
104 | cancelText: "取消",
105 | onOk() {
106 | deleteProject({ id });
107 | },
108 | });
109 | };
110 | return (
111 |
114 |
115 | 编辑
116 |
117 | confirmDeleteProject(project.id)}
120 | >
121 | 删除
122 |
123 |
124 | }
125 | >
126 | ...
127 |
128 | );
129 | };
130 |
--------------------------------------------------------------------------------
/src/screens/project-list/project-modal.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { Button, Drawer, Form, Input, Spin } from "antd";
3 | import {
4 | useAddProject,
5 | useEditProject,
6 | useProjectModal,
7 | useProjectQueryKey,
8 | } from "./utils";
9 | import { UserSelect } from "components/user-select";
10 | import { useForm } from "antd/lib/form/Form";
11 | import { ErrorBox } from "components/lib";
12 | import styled from "@emotion/styled";
13 |
14 | export const ProjectModal = () => {
15 | const {
16 | projectModalOpen,
17 | close,
18 | editingProject,
19 | isLoading,
20 | } = useProjectModal();
21 | const useMutateProject = editingProject ? useEditProject : useAddProject;
22 |
23 | const { mutateAsync, error, isLoading: mutateLoading } = useMutateProject(
24 | useProjectQueryKey()
25 | );
26 | const [form] = useForm();
27 |
28 | const onFinish = (values: any) => {
29 | mutateAsync({ ...editingProject, ...values }).then(() => {
30 | form.resetFields();
31 | close();
32 | });
33 | };
34 |
35 | const closeModal = () => {
36 | form.resetFields();
37 | close();
38 | };
39 |
40 | const title = editingProject ? "编辑项目" : "创建项目";
41 |
42 | useEffect(() => {
43 | form.setFieldsValue(editingProject);
44 | }, [editingProject, form]);
45 |
46 | return (
47 |
53 |
54 | {isLoading ? (
55 |
56 | ) : (
57 | <>
58 | {title}
59 |
60 |
71 |
72 |
73 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
91 |
92 |
93 | >
94 | )}
95 |
96 |
97 | );
98 | };
99 |
100 | const Container = styled.div`
101 | height: 80vh;
102 | display: flex;
103 | flex-direction: column;
104 | justify-content: center;
105 | align-items: center;
106 | `;
107 |
--------------------------------------------------------------------------------
/src/screens/project-list/search-pannel.tsx:
--------------------------------------------------------------------------------
1 | import { Input, Form } from "antd";
2 | import { UserSelect } from "components/user-select";
3 | import { Project } from "./list";
4 |
5 | export interface User {
6 | id: number;
7 | name: string;
8 | personId: number;
9 | email: string;
10 | title: string;
11 | organization: string;
12 | token: string;
13 | }
14 |
15 | interface SearchPannelProps {
16 | users: User[];
17 | param: Partial>;
18 | setParam: (param: SearchPannelProps["param"]) => void;
19 | }
20 | export const SearchPannel = ({ param, setParam }: SearchPannelProps) => {
21 | return (
22 |
24 |
29 | setParam({
30 | ...param,
31 | name: evt.target.value,
32 | })
33 | }
34 | >
35 |
36 |
37 |
41 | setParam({
42 | ...param,
43 | personId: value,
44 | })
45 | }
46 | />
47 |
48 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/src/screens/project-list/utils.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { QueryKey, useMutation, useQuery } from "react-query";
3 | import { useHttp } from "utils/http";
4 | import { useSetUrlSearchParam, useUrlQueryParam } from "utils/url";
5 | import {
6 | useAddConfig,
7 | useDeleteConfig,
8 | useEditConfig,
9 | } from "utils/use-optimistic-options";
10 | import { Project } from "./list";
11 | export const useProjectSearchParam = () => {
12 | const [param, setParam] = useUrlQueryParam(["name", "personId"]);
13 | return [
14 | useMemo(() => {
15 | return {
16 | ...param,
17 | personId: Number(param.personId) || undefined,
18 | };
19 | }, [param]),
20 | setParam,
21 | ] as const;
22 | };
23 |
24 | export const useProjectQueryKey = () => {
25 | const [params] = useProjectSearchParam();
26 | return ["projects", params];
27 | };
28 |
29 | export const useEditProject = (queryKey: QueryKey) => {
30 | const client = useHttp();
31 | return useMutation(
32 | (params: Partial) =>
33 | client(`projects/${params.id}`, {
34 | method: "PATCH",
35 | data: params,
36 | }),
37 | useEditConfig(queryKey)
38 | );
39 | };
40 |
41 | export const useAddProject = (queryKey: QueryKey) => {
42 | const client = useHttp();
43 | return useMutation(
44 | (params: Partial) =>
45 | client(`projects`, {
46 | data: params,
47 | method: "POST",
48 | }),
49 | useAddConfig(queryKey)
50 | );
51 | };
52 |
53 | export const useDeleteProject = (queryKey: QueryKey) => {
54 | const client = useHttp();
55 | return useMutation(
56 | ({ id }: { id: number }) =>
57 | client(`projects/${id}`, {
58 | method: "DELETE",
59 | }),
60 | useDeleteConfig(queryKey)
61 | );
62 | };
63 |
64 | export const useProjectModal = () => {
65 | const [{ projectCreate }, setProjectModalOpen] = useUrlQueryParam([
66 | "projectCreate",
67 | ]);
68 | const [{ editingProjectId }, setEditingProjectId] = useUrlQueryParam([
69 | "editingProjectId",
70 | ]);
71 |
72 | const setUrlParams = useSetUrlSearchParam();
73 |
74 | const { data: editingProject, isLoading } = useProject(
75 | Number(editingProjectId)
76 | );
77 |
78 | const open = () => setProjectModalOpen({ projectCreate: true });
79 |
80 | const close = () => setUrlParams({ projectCreate: "", editingProjectId: "" });
81 |
82 | const startEdit = (id: number) =>
83 | setEditingProjectId({ editingProjectId: id });
84 | return {
85 | projectModalOpen: projectCreate === "true" || Boolean(editingProjectId),
86 | open,
87 | close,
88 | startEdit,
89 | editingProject,
90 | isLoading,
91 | };
92 | };
93 |
94 | export const useProject = (id?: number) => {
95 | const client = useHttp();
96 | return useQuery(
97 | ["project", { id }],
98 | () => client(`projects/${id}`),
99 | {
100 | enabled: Boolean(id),
101 | }
102 | );
103 | };
104 |
--------------------------------------------------------------------------------
/src/screens/project/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { Route, Routes, Navigate, useLocation } from "react-router";
3 | import { EpicScreen } from "screens/epic";
4 | import { KanbanScreen } from "screens/kanban";
5 | import styled from "@emotion/styled";
6 | import { Menu } from "antd";
7 |
8 | const useRouteType = () => {
9 | const units = useLocation().pathname.split("/");
10 | return units[units.length - 1];
11 | };
12 |
13 | export const ProjectScreen = () => {
14 | const selected = useRouteType();
15 | return (
16 |
17 |
27 |
28 |
29 | }>
30 | }>
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | const Aside = styled.aside`
39 | background-color: rgb(244, 245, 247);
40 | `;
41 |
42 | const Main = styled.div`
43 | box-shadow: -5px 0 5px -5px rgba(0, 0, 0, 0.1);
44 | display: flex;
45 | `;
46 |
47 | const Container = styled.div`
48 | display: grid;
49 | grid-template-columns: 16rem 1fr;
50 | `;
51 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export type Raw = string | number;
2 |
--------------------------------------------------------------------------------
/src/types/kanban.ts:
--------------------------------------------------------------------------------
1 | export interface Kanban {
2 | id: number;
3 | name: string;
4 | projectId: number;
5 | }
6 |
--------------------------------------------------------------------------------
/src/types/task-type.ts:
--------------------------------------------------------------------------------
1 | export interface TaskType {
2 | id: number;
3 | name: string;
4 | }
5 |
--------------------------------------------------------------------------------
/src/types/task.ts:
--------------------------------------------------------------------------------
1 | export interface Task {
2 | id: number;
3 | name: string;
4 | // 经办人
5 | processorId: number;
6 | projectId: number;
7 | // 任务组
8 | epicId: number;
9 | kanbanId: number;
10 | // bug or task
11 | typeId: number;
12 | note: string;
13 | }
14 |
--------------------------------------------------------------------------------
/src/unauthenticated-app/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { RegisterScreen } from "unauthenticated-app/register";
3 | import { LoginScreen } from "unauthenticated-app/login";
4 | import { Button, Card, Divider } from "antd";
5 | import styled from "@emotion/styled";
6 | import logo from "assets/logo.svg";
7 | import left from "assets/left.svg";
8 | import right from "assets/right.svg";
9 | import { useDocumentTitle } from "hooks";
10 | import { ErrorBox } from "components/lib";
11 |
12 | export const UnauthenticatedApp = () => {
13 | const [isRegister, setIsRegister] = useState(false);
14 | const [error, setError] = useState(null);
15 | useDocumentTitle("请登陆注册以继续");
16 | return (
17 |
18 |
19 |
20 |
21 | {isRegister ? "请注册" : "请登录"}
22 |
23 | {isRegister ? (
24 |
25 | ) : (
26 |
27 | )}
28 |
29 |
32 |
33 |
34 | );
35 | };
36 |
37 | export const LongButton = styled(Button)`
38 | width: 100%;
39 | `;
40 |
41 | const Title = styled.h2`
42 | margin-bottom: 2.4rem;
43 | color: rgb(94, 108, 132);
44 | `;
45 |
46 | const Background = styled.div`
47 | position: absolute;
48 | width: 100%;
49 | height: 100%;
50 | background-repeat: no-repeat;
51 | background-attachment: fixed; // 决定背景图片是否会随页面滑动而滑动
52 | background-position: left bottom, right bottom;
53 | background-size: calc(((100vw - 40rem) / 2) - 3.2rem),
54 | calc(((100vw - 40rem) / 2) - 3.2rem), cover;
55 | background-image: url(${left}), url(${right});
56 | `;
57 |
58 | const Header = styled.header`
59 | background: url(${logo}) no-repeat center;
60 | padding: 5rem 0;
61 | background-size: 8rem;
62 | width: 100%;
63 | `;
64 |
65 | const ShadowCard = styled(Card)`
66 | width: 40rem;
67 | min-height: 56rem;
68 | padding: 3.6rem 4rem;
69 | border-radius: 0.3rem;
70 | box-sizing: border-box;
71 | box-shadow: rgba(0, 0, 0, 0.1) 0 0 10px;
72 | text-align: center;
73 | `;
74 |
75 | const Container = styled.div`
76 | display: flex;
77 | flex-direction: column;
78 | align-items: center;
79 | min-height: 100vh;
80 | `;
81 |
--------------------------------------------------------------------------------
/src/unauthenticated-app/login.tsx:
--------------------------------------------------------------------------------
1 | import { useAuth } from "context/auth-context";
2 | import { Form, Input } from "antd";
3 | import { LongButton } from "./index";
4 | import { useAsync } from "hooks/useAsync";
5 |
6 | export const LoginScreen = ({
7 | onError,
8 | }: {
9 | onError: (error: Error) => void;
10 | }) => {
11 | const { login } = useAuth();
12 | const { run, isLoading } = useAsync(undefined, { throwOnError: true });
13 | const handleSubmit = async (values: {
14 | username: string;
15 | password: string;
16 | }) => {
17 | try {
18 | await run(login(values));
19 | } catch (e) {
20 | onError(e);
21 | }
22 | };
23 | return (
24 |
29 |
30 |
31 |
35 |
36 |
37 |
38 |
39 | 登录
40 |
41 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/unauthenticated-app/register.tsx:
--------------------------------------------------------------------------------
1 | import { useAuth } from "context/auth-context";
2 | import { Form, Input } from "antd";
3 | import { LongButton } from "./index";
4 | import { useAsync } from "hooks/useAsync";
5 |
6 | export const RegisterScreen = ({
7 | onError,
8 | }: {
9 | onError: (error: Error) => void;
10 | }) => {
11 | const { register } = useAuth();
12 | const { run, isLoading } = useAsync(undefined, { throwOnError: true });
13 | const handleSubmit = async ({
14 | cpassword,
15 | ...values
16 | }: {
17 | username: string;
18 | password: string;
19 | cpassword: string;
20 | }) => {
21 | if (cpassword !== values.password) {
22 | onError(new Error("请确认两次输入的密码相同"));
23 | return;
24 | }
25 | try {
26 | await run(register(values));
27 | } catch (e) {
28 | onError(e);
29 | }
30 | };
31 | return (
32 |
37 |
38 |
39 |
43 |
44 |
45 |
49 |
50 |
51 |
52 |
53 | 注册
54 |
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/src/utils/http.ts:
--------------------------------------------------------------------------------
1 | import qs from "qs";
2 | import * as auth from "auth-provider";
3 | import { useAuth } from "context/auth-context";
4 | import { useCallback } from "react";
5 | const apiUrl = process.env.REACT_APP_API_URL;
6 | interface Config extends RequestInit {
7 | data?: object;
8 | token?: string;
9 | }
10 | export const http = async (
11 | endpoint: string,
12 | { data, token, headers, ...customConfig }: Config = {}
13 | ) => {
14 | const config = {
15 | method: "GET",
16 | headers: {
17 | Authorization: token ? `Bearer ${token}` : "",
18 | "Content-Type": data ? "application/json" : "",
19 | },
20 | ...customConfig,
21 | };
22 | if (config.method.toUpperCase() === "GET") {
23 | endpoint += `?${qs.stringify(data)}`;
24 | } else {
25 | config.body = JSON.stringify(data || {});
26 | }
27 | // axios 和 fetch 的表现不一样, axios在返回状态不为2xx的时候抛出异常
28 | return window
29 | .fetch(`${apiUrl}/${endpoint}`, config)
30 | .then(async (response) => {
31 | if (response.status === 401) {
32 | // 退出登录
33 | await auth.logout();
34 | window.location.reload();
35 | return Promise.reject({ message: "请重新登录" });
36 | }
37 | const data = await response.json();
38 | if (response.ok) {
39 | return data;
40 | } else {
41 | return Promise.reject(data);
42 | }
43 | });
44 | };
45 |
46 | export const useHttp = () => {
47 | const { user } = useAuth();
48 | return useCallback(
49 | (...[endpoint, config]: Parameters) =>
50 | http(endpoint, { ...config, token: user?.token || "" }),
51 | [user?.token]
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export const isFalsy = (value: unknown) => (value === 0 ? false : !value);
2 |
3 | export const isVoid = (value: unknown) =>
4 | value === null || value === undefined || value === "";
5 |
6 | export const cleanObject = (object: { [key: string]: unknown }) => {
7 | const result = { ...object };
8 | Object.keys(result).forEach((key) => {
9 | const value = result[key];
10 | if (isVoid(value)) {
11 | delete result[key];
12 | }
13 | });
14 | return result;
15 | };
16 |
17 | /* export const debounce = (func: () => void, delay?: number) => {
18 | let timeout: number;
19 | return (...param) => {
20 | if (timeout) {
21 | clearTimeout(timeout);
22 | }
23 | timeout = setTimeout(() => {
24 | func(...param);
25 | }, delay);
26 | };
27 | }; */
28 |
29 | export const resetRoute = () => (window.location.href = window.location.origin);
30 |
--------------------------------------------------------------------------------
/src/utils/kanban.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "react-query";
2 | import { Kanban } from "types/kanban";
3 | import { useHttp } from "utils/http";
4 |
5 | export const useKanbans = (params?: Partial) => {
6 | const client = useHttp();
7 | return useQuery(["kanbans", params], () =>
8 | client("kanbans", { data: params })
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/utils/task-type.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "react-query";
2 | import { TaskType } from "types/task-type";
3 | import { useHttp } from "utils/http";
4 |
5 | export const useTaskTypes = () => {
6 | const client = useHttp();
7 | return useQuery(["taskTypes"], () => client("taskTypes"));
8 | };
9 |
--------------------------------------------------------------------------------
/src/utils/task.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "react-query";
2 | import { Task } from "types/task";
3 | import { useHttp } from "utils/http";
4 |
5 | export const useTasks = (params?: Partial) => {
6 | const client = useHttp();
7 | return useQuery(["tasks", params], () =>
8 | client("tasks", { data: params })
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/utils/url.ts:
--------------------------------------------------------------------------------
1 | import { useMemo, useState } from "react";
2 | import { URLSearchParamsInit, useSearchParams } from "react-router-dom";
3 | import { cleanObject } from "utils";
4 | /**
5 | * 返回页面url中指定键的参数值
6 | */
7 | export const useUrlQueryParam = (keys: K[]) => {
8 | const [searchParams] = useSearchParams();
9 | const setSearchParams = useSetUrlSearchParam();
10 | const [stateKeys] = useState(keys);
11 | return [
12 | useMemo(
13 | () =>
14 | stateKeys.reduce((prev, key) => {
15 | return { ...prev, [key]: searchParams.get(key) || "" };
16 | }, {} as { [key in K]: string }),
17 | [searchParams, stateKeys]
18 | ),
19 | (params: Partial<{ [key in K]: unknown }>) => {
20 | return setSearchParams(params);
21 | },
22 | ] as const;
23 | };
24 |
25 | export const useSetUrlSearchParam = () => {
26 | const [searchParams, setSearchParams] = useSearchParams();
27 | return (params: { [key in string]: unknown }) => {
28 | const o = cleanObject({
29 | ...Object.fromEntries(searchParams),
30 | ...params,
31 | }) as URLSearchParamsInit;
32 | return setSearchParams(o);
33 | };
34 | };
35 |
--------------------------------------------------------------------------------
/src/utils/use-optimistic-options.ts:
--------------------------------------------------------------------------------
1 | import { QueryKey, useQueryClient } from "react-query";
2 |
3 | export const useConfig = (
4 | queryKey: QueryKey,
5 | callback: (target: any, old?: any[]) => any[]
6 | ) => {
7 | const queryClient = useQueryClient();
8 | return {
9 | onSuccess: () => queryClient.invalidateQueries(queryKey),
10 | async onMutate(target: any) {
11 | const previousItems = queryClient.getQueryData(queryKey);
12 | queryClient.setQueryData(queryKey, (old?: any[]) => {
13 | return callback(target, old);
14 | });
15 | return { previousItems };
16 | },
17 | onError(error: any, newItem: any, context: any) {
18 | queryClient.setQueryData(queryKey, context.previousItems);
19 | },
20 | };
21 | };
22 |
23 | export const useDeleteConfig = (queryKey: QueryKey) =>
24 | useConfig(
25 | queryKey,
26 | (target, old) => old?.filter((item) => item.id !== target.id) || []
27 | );
28 |
29 | export const useEditConfig = (queryKey: QueryKey) =>
30 | useConfig(
31 | queryKey,
32 | (target, old) =>
33 | old?.map((item) =>
34 | item.id === target.id ? { ...item, ...target } : item
35 | ) || []
36 | );
37 |
38 | export const useAddConfig = (queryKey: QueryKey) =>
39 | useConfig(queryKey, (target, old) => (old ? [...old, target] : []));
40 |
--------------------------------------------------------------------------------
/src/wdyr.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | if (process.env.NODE_ENV === "development") {
4 | const whyDidYouRender = require("@welldone-software/why-did-you-render");
5 | whyDidYouRender(React, {
6 | trackAllPureComponents: true,
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./src",
4 | "target": "es5",
5 | "lib": [
6 | "dom",
7 | "dom.iterable",
8 | "esnext"
9 | ],
10 | "allowJs": true,
11 | "skipLibCheck": true,
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "noFallthroughCasesInSwitch": true,
17 | "module": "esnext",
18 | "moduleResolution": "node",
19 | "resolveJsonModule": true,
20 | "isolatedModules": true,
21 | "noEmit": true,
22 | "jsx": "react-jsx"
23 | },
24 | "include": [
25 | "src"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------