Skip to content

Commit 5dfa188

Browse files
committed
Added edit category page
1 parent e95f21b commit 5dfa188

File tree

8 files changed

+284
-25
lines changed

8 files changed

+284
-25
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { CategoryList } from "@dmm-com/airone-apiclient-typescript-fetch";
2+
import { z } from "zod";
3+
4+
import { schemaForType } from "../../../services/ZodSchemaUtil";
5+
6+
type CategoryForSchema = Omit<CategoryList, "isEditable">;
7+
8+
export const schema = schemaForType<CategoryForSchema>()(
9+
z
10+
.object({
11+
id: z.number().default(0),
12+
name: z.string().min(1, { message: "カテゴリ名は必須です" }).default(""),
13+
note: z.string().optional(),
14+
models: z
15+
.array(
16+
z.object({
17+
id: z.number(),
18+
name: z.string(),
19+
})
20+
)
21+
.default([]),
22+
})
23+
.superRefine(({ }, ctx) => {
24+
/*
25+
const userIds = users.map((u) => u.id);
26+
const groupIds = groups.map((g) => g.id);
27+
const adminUserIds = adminUsers.map((u) => u.id);
28+
const adminGroupIds = adminGroups.map((g) => g.id);
29+
30+
if (adminUserIds.length === 0 && adminGroupIds.length === 0) {
31+
ctx.addIssue({
32+
path: ["adminUsers"],
33+
code: z.ZodIssueCode.custom,
34+
message:
35+
"管理者ユーザーか管理者グループのどちらかは必ずメンバーを指定してください",
36+
});
37+
ctx.addIssue({
38+
path: ["adminGroups"],
39+
code: z.ZodIssueCode.custom,
40+
message:
41+
"管理者ユーザーか管理者グループのどちらかは必ずメンバーを指定してください",
42+
});
43+
}
44+
45+
userIds
46+
.flatMap((id, index) => (adminUserIds.includes(id) ? [index] : []))
47+
.forEach((index) => {
48+
ctx.addIssue({
49+
path: ["users", index], // NOTE: Nested path to feedback a concrete error info.
50+
code: z.ZodIssueCode.custom,
51+
message: "管理者と重複しているユーザーがあります",
52+
});
53+
});
54+
*/
55+
56+
})
57+
);
58+
59+
export type Schema = z.infer<typeof schema>;

frontend/src/components/common/Header.tsx

+13-9
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
entitiesPath,
3434
groupsPath,
3535
jobsPath,
36+
listCategoryPath,
3637
loginPath,
3738
rolesPath,
3839
topPath,
@@ -52,7 +53,7 @@ import {
5253
} from "services/JobUtil";
5354
import { ServerContext } from "services/ServerContext";
5455

55-
const Frame = styled(Box)(({}) => ({
56+
const Frame = styled(Box)(({ }) => ({
5657
width: "100%",
5758
height: "56px",
5859
}));
@@ -70,21 +71,21 @@ const StyledAppBar = styled(AppBar)(({ theme }) => ({
7071
maxWidth: theme.breakpoints.values.lg,
7172
}));
7273

73-
const StyledToolbar = styled(Toolbar)(({}) => ({
74+
const StyledToolbar = styled(Toolbar)(({ }) => ({
7475
height: "56px",
7576
}));
7677

77-
const TitleBox = styled(Box)(({}) => ({
78+
const TitleBox = styled(Box)(({ }) => ({
7879
display: "flex",
7980
alignItems: "center",
8081
}));
8182

82-
const Title = styled(Typography)(({}) => ({
83+
const Title = styled(Typography)(({ }) => ({
8384
color: "white",
8485
textDecoration: "none",
8586
})) as OverridableComponent<TypographyTypeMap>;
8687

87-
const Version = styled(Typography)(({}) => ({
88+
const Version = styled(Typography)(({ }) => ({
8889
color: "#FFFFFF8A",
8990
paddingLeft: "20px",
9091
maxWidth: "64px",
@@ -93,7 +94,7 @@ const Version = styled(Typography)(({}) => ({
9394
whiteSpace: "nowrap",
9495
}));
9596

96-
const MenuBox = styled(Box)(({}) => ({
97+
const MenuBox = styled(Box)(({ }) => ({
9798
flexGrow: 1,
9899
display: "flex",
99100
color: "white",
@@ -108,7 +109,7 @@ const MenuBox = styled(Box)(({}) => ({
108109
},
109110
}));
110111

111-
const SearchBoxWrapper = styled(Box)(({}) => ({
112+
const SearchBoxWrapper = styled(Box)(({ }) => ({
112113
display: "flex",
113114
alignItems: "center",
114115
width: "240px",
@@ -177,6 +178,9 @@ export const Header: FC = () => {
177178
</TitleBox>
178179

179180
<MenuBox>
181+
<Button component={Link} to={listCategoryPath()}>
182+
{t("categories")}
183+
</Button>
180184
<Button component={Link} to={entitiesPath()}>
181185
{t("entities")}
182186
</Button>
@@ -286,8 +290,8 @@ export const Header: FC = () => {
286290
job.operation == JobOperations.EXPORT_SEARCH_RESULT ||
287291
job.operation == JobOperations.EXPORT_ENTRY_V2 ||
288292
job.operation ==
289-
JobOperations.EXPORT_SEARCH_RESULT_V2) &&
290-
job.status == JobStatuses.DONE ? (
293+
JobOperations.EXPORT_SEARCH_RESULT_V2) &&
294+
job.status == JobStatuses.DONE ? (
291295
<a href={`/job/api/v2/${job.id}/download?encode=utf-8`}>
292296
{jobTargetLabel(job)}
293297
</a>

frontend/src/i18n/config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import i18n, { Resource } from "i18next";
22
import { initReactI18next } from "react-i18next";
33

44
export type TranslationKey =
5+
| "categories"
56
| "entities"
67
| "advancedSearch"
78
| "management"
@@ -43,6 +44,7 @@ function toResource(resource: AironeResource): Resource {
4344
const resources = toResource({
4445
en: {
4546
translation: {
47+
categories: "Categories",
4648
entities: "Entities",
4749
advancedSearch: "Advanced Search",
4850
management: "Management",
@@ -60,6 +62,7 @@ const resources = toResource({
6062
},
6163
ja: {
6264
translation: {
65+
categories: "カテゴリ一覧",
6366
entities: "モデル一覧",
6467
advancedSearch: "高度な検索",
6568
management: "管理機能",
+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { zodResolver } from "@hookform/resolvers/zod";
2+
import { Box, Container, Typography } from "@mui/material";
3+
import React, { FC, useCallback, useEffect } from "react";
4+
import { useForm } from "react-hook-form";
5+
import { Link, useNavigate } from "react-router-dom";
6+
7+
import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow";
8+
9+
import { AironeBreadcrumbs } from "components/common/AironeBreadcrumbs";
10+
import { Loading } from "components/common/Loading";
11+
import { PageHeader } from "components/common/PageHeader";
12+
import { SubmitButton } from "components/common/SubmitButton";
13+
import { Schema, schema } from "components/role/roleForm/RoleFormSchema";
14+
import { useFormNotification } from "hooks/useFormNotification";
15+
import { usePrompt } from "hooks/usePrompt";
16+
import { useTypedParams } from "hooks/useTypedParams";
17+
import { aironeApiClient } from "repository/AironeApiClient";
18+
import { listCategoryPath, rolesPath, topPath } from "routes/Routes";
19+
20+
export const CategoryEditPage: FC = () => {
21+
const { categoryId } = useTypedParams<{ categoryId?: number }>();
22+
const willCreate = categoryId == null;
23+
24+
const navigate = useNavigate();
25+
const { enqueueSubmitResult } = useFormNotification("カテゴリ", willCreate);
26+
27+
const {
28+
formState: { isValid, isDirty, isSubmitting, isSubmitSuccessful },
29+
handleSubmit,
30+
reset,
31+
setError,
32+
setValue,
33+
control,
34+
} = useForm<Schema>({
35+
resolver: zodResolver(schema),
36+
mode: "onBlur",
37+
});
38+
39+
usePrompt(
40+
isDirty && !isSubmitSuccessful,
41+
"編集した内容は失われてしまいますが、このページを離れてもよろしいですか?"
42+
);
43+
44+
const category = useAsyncWithThrow(async () => {
45+
return categoryId != null ? await aironeApiClient.getCategory(categoryId) : undefined;
46+
}, [categoryId]);
47+
48+
useEffect(() => {
49+
!category.loading && category.value != null && reset(category.value);
50+
}, [category.loading]);
51+
52+
useEffect(() => {
53+
isSubmitSuccessful && navigate(rolesPath());
54+
}, [isSubmitSuccessful]);
55+
56+
const handleSubmitOnValid = useCallback(
57+
async (category: Schema) => {
58+
/*
59+
const roleCreateUpdate: RoleCreateUpdate = {
60+
...role,
61+
users: role.users.map((user) => user.id),
62+
groups: role.groups.map((group) => group.id),
63+
adminUsers: role.adminUsers.map((user) => user.id),
64+
adminGroups: role.adminGroups.map((group) => group.id),
65+
};
66+
67+
try {
68+
if (willCreate) {
69+
await aironeApiClient.createRole(roleCreateUpdate);
70+
} else {
71+
await aironeApiClient.updateRole(roleId, roleCreateUpdate);
72+
}
73+
enqueueSubmitResult(true);
74+
} catch (e) {
75+
if (e instanceof Error && isResponseError(e)) {
76+
await extractAPIException<Schema>(
77+
e,
78+
(message) => enqueueSubmitResult(false, `詳細: "${message}"`),
79+
(name, message) => {
80+
setError(name, { type: "custom", message: message });
81+
enqueueSubmitResult(false);
82+
}
83+
);
84+
} else {
85+
enqueueSubmitResult(false);
86+
}
87+
}
88+
*/
89+
},
90+
[categoryId]
91+
);
92+
93+
const handleCancel = async () => {
94+
navigate(-1);
95+
};
96+
97+
if (category.loading) {
98+
return <Loading />;
99+
}
100+
101+
return (
102+
<Box className="container-fluid">
103+
<AironeBreadcrumbs>
104+
<Typography component={Link} to={topPath()}>
105+
Top
106+
</Typography>
107+
<Typography component={Link} to={listCategoryPath()}>
108+
カテゴリ一覧
109+
</Typography>
110+
<Typography color="textPrimary">カテゴリ編集</Typography>
111+
</AironeBreadcrumbs>
112+
113+
<PageHeader
114+
title={category.value != null ? category.value.name : "新規カテゴリの作成"}
115+
description={category.value != null ? "カテゴリ編集" : undefined}
116+
>
117+
<SubmitButton
118+
name="保存"
119+
disabled={
120+
!isDirty ||
121+
!isValid ||
122+
isSubmitting ||
123+
isSubmitSuccessful
124+
//category.value?. === false
125+
}
126+
isSubmitting={isSubmitting}
127+
handleSubmit={handleSubmit(handleSubmitOnValid)}
128+
handleCancel={handleCancel}
129+
/>
130+
</PageHeader>
131+
132+
<Container>
133+
{/*
134+
<RoleForm control={control} setValue={setValue} />
135+
*/
136+
}
137+
</Container>
138+
</Box>
139+
);
140+
};

frontend/src/pages/ListCategoryPage.tsx

+53-11
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
1-
import AppsIcon from "@mui/icons-material/Apps";
2-
import { Box, Container, IconButton, Typography } from "@mui/material";
3-
import { Link } from "react-router-dom";
1+
import { Box, Container, Grid, List, ListItem, ListItemText, Typography } from "@mui/material";
42
import React, { FC, useState } from "react";
3+
import { Link } from "react-router-dom";
54

65
import { AironeBreadcrumbs } from "components/common/AironeBreadcrumbs";
7-
import { EntityControlMenu } from "../components/entity/EntityControlMenu";
8-
import { useAsyncWithThrow } from "../hooks/useAsyncWithThrow";
96
import { useTypedParams } from "../hooks/useTypedParams";
107

118
import { PageHeader } from "components/common/PageHeader";
12-
import { EntityBreadcrumbs } from "components/entity/EntityBreadcrumbs";
13-
import { EntryImportModal } from "components/entry/EntryImportModal";
14-
import { EntryList } from "components/entry/EntryList";
15-
import { aironeApiClient } from "repository/AironeApiClient";
169
import { topPath } from "routes/Routes";
1710

1811
interface Props {
@@ -44,9 +37,58 @@ export const ListCategoryPage: FC<Props> = ({ }) => {
4437
title={"カテゴリ一覧"}
4538
>
4639
</PageHeader>
47-
4840
<Container>
49-
<>TBD: Main Context</>
41+
<Grid container spacing={3}>
42+
<Grid item md={4}>
43+
<List
44+
subheader={
45+
<Typography variant="h6" component="div">
46+
インフラ機器
47+
</Typography>
48+
}
49+
>
50+
<ListItem button component={Link} to={`/categories/${categoryId}/entities`}>
51+
<ListItemText primary="サーバ" />
52+
</ListItem>
53+
<ListItem button component={Link} to={`/categories/${categoryId}/entities`}>
54+
<ListItemText primary="switch" />
55+
</ListItem>
56+
</List>
57+
</Grid>
58+
<Grid item md={4}>
59+
<List
60+
subheader={
61+
<Typography variant="h6" component="div">
62+
インフラ機器
63+
</Typography>
64+
}
65+
>
66+
<ListItem button component={Link} to={`/categories/${categoryId}/entities`}>
67+
<ListItemText primary="サーバ" />
68+
</ListItem>
69+
<ListItem button component={Link} to={`/categories/${categoryId}/entities`}>
70+
<ListItemText primary="switch" />
71+
</ListItem>
72+
</List>
73+
</Grid>
74+
<Grid item md={4}>
75+
<List
76+
subheader={
77+
<Typography variant="h6" component="div">
78+
インフラ機器
79+
</Typography>
80+
}
81+
>
82+
<ListItem button component={Link} to={`/categories/${categoryId}/entities`}>
83+
<ListItemText primary="サーバ" />
84+
</ListItem>
85+
<ListItem button component={Link} to={`/categories/${categoryId}/entities`}>
86+
<ListItemText primary="switch" />
87+
</ListItem>
88+
</List>
89+
</Grid>
90+
</Grid>
91+
5092
</Container>
5193
</Box>
5294
);

0 commit comments

Comments
 (0)