Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit0d25e17

Browse files
authored
feat: Add filter on Users page (#2653)
This commit adds a new filter feature to the Users page.- adds a filter to the getUsers API call and users state machine.- adds filter UI to Users page view.- addresses error handling in the filter component, users page and machine.- refactors user table code.- refactors common code for workspace filter.- adds and updates unit tests and stories.
1 parentcb2d1f4 commit0d25e17

22 files changed

+503
-249
lines changed

‎site/src/api/api.test.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
importaxiosfrom"axios"
2-
import{getApiKey,getWorkspacesURL,login,logout}from"./api"
2+
import{getApiKey,getURLWithSearchParams,login,logout}from"./api"
33
import*asTypesGenfrom"./typesGenerated"
44

55
describe("api.ts",()=>{
@@ -114,16 +114,26 @@ describe("api.ts", () => {
114114
})
115115
})
116116

117-
describe("getWorkspacesURL",()=>{
118-
it.each<[TypesGen.WorkspaceFilter|undefined,string]>([
119-
[undefined,"/api/v2/workspaces"],
117+
describe("getURLWithSearchParams - workspaces",()=>{
118+
it.each<[string,TypesGen.WorkspaceFilter|undefined,string]>([
119+
["/api/v2/workspaces",undefined,"/api/v2/workspaces"],
120120

121-
[{q:""},"/api/v2/workspaces"],
122-
[{q:"owner:1"},"/api/v2/workspaces?q=owner%3A1"],
121+
["/api/v2/workspaces",{q:""},"/api/v2/workspaces"],
122+
["/api/v2/workspaces",{q:"owner:1"},"/api/v2/workspaces?q=owner%3A1"],
123123

124-
[{q:"owner:me"},"/api/v2/workspaces?q=owner%3Ame"],
125-
])(`getWorkspacesURL(%p) returns %p`,(filter,expected)=>{
126-
expect(getWorkspacesURL(filter)).toBe(expected)
124+
["/api/v2/workspaces",{q:"owner:me"},"/api/v2/workspaces?q=owner%3Ame"],
125+
])(`Workspaces - getURLWithSearchParams(%p, %p) returns %p`,(basePath,filter,expected)=>{
126+
expect(getURLWithSearchParams(basePath,filter)).toBe(expected)
127+
})
128+
})
129+
130+
describe("getURLWithSearchParams - users",()=>{
131+
it.each<[string,TypesGen.UsersRequest|undefined,string]>([
132+
["/api/v2/users",undefined,"/api/v2/users"],
133+
["/api/v2/users",{q:"status:active"},"/api/v2/users?q=status%3Aactive"],
134+
["/api/v2/users",{q:""},"/api/v2/users"],
135+
])(`Users - getURLWithSearchParams(%p, %p) returns %p`,(basePath,filter,expected)=>{
136+
expect(getURLWithSearchParams(basePath,filter)).toBe(expected)
127137
})
128138
})
129139
})

‎site/src/api/api.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
7272
returnresponse.data
7373
}
7474

75-
exportconstgetUsers=async():Promise<TypesGen.User[]>=>{
76-
constresponse=awaitaxios.get<TypesGen.User[]>("/api/v2/users?q=status:active,suspended")
75+
exportconstgetUsers=async(filter?:TypesGen.UsersRequest):Promise<TypesGen.User[]>=>{
76+
consturl=getURLWithSearchParams("/api/v2/users",filter)
77+
constresponse=awaitaxios.get<TypesGen.User[]>(url)
7778
returnresponse.data
7879
}
7980

@@ -144,8 +145,10 @@ export const getWorkspace = async (
144145
returnresponse.data
145146
}
146147

147-
exportconstgetWorkspacesURL=(filter?:TypesGen.WorkspaceFilter):string=>{
148-
constbasePath="/api/v2/workspaces"
148+
exportconstgetURLWithSearchParams=(
149+
basePath:string,
150+
filter?:TypesGen.WorkspaceFilter|TypesGen.UsersRequest,
151+
):string=>{
149152
constsearchParams=newURLSearchParams()
150153

151154
if(filter?.q&&filter.q!==""){
@@ -160,7 +163,7 @@ export const getWorkspacesURL = (filter?: TypesGen.WorkspaceFilter): string => {
160163
exportconstgetWorkspaces=async(
161164
filter?:TypesGen.WorkspaceFilter,
162165
):Promise<TypesGen.Workspace[]>=>{
163-
consturl=getWorkspacesURL(filter)
166+
consturl=getURLWithSearchParams("/api/v2/workspaces",filter)
164167
constresponse=awaitaxios.get<TypesGen.Workspace[]>(url)
165168
returnresponse.data
166169
}

‎site/src/api/errors.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import{isApiError,mapApiErrorToFieldErrors}from"./errors"
1+
import{getValidationErrorMessage,isApiError,mapApiErrorToFieldErrors}from"./errors"
22

33
describe("isApiError",()=>{
44
it("returns true when the object is an API Error",()=>{
@@ -36,3 +36,57 @@ describe("mapApiErrorToFieldErrors", () => {
3636
})
3737
})
3838
})
39+
40+
describe("getValidationErrorMessage",()=>{
41+
it("returns multiple validation messages",()=>{
42+
expect(
43+
getValidationErrorMessage({
44+
response:{
45+
data:{
46+
message:"Invalid user search query.",
47+
validations:[
48+
{
49+
field:"status",
50+
detail:`Query param "status" has invalid value: "inactive" is not a valid user status`,
51+
},
52+
{
53+
field:"q",
54+
detail:`Query element "role:a:e" can only contain 1 ':'`,
55+
},
56+
],
57+
},
58+
},
59+
isAxiosError:true,
60+
}),
61+
).toEqual(
62+
`Query param "status" has invalid value: "inactive" is not a valid user status\nQuery element "role:a:e" can only contain 1 ':'`,
63+
)
64+
})
65+
66+
it("non-API error returns empty validation message",()=>{
67+
expect(
68+
getValidationErrorMessage({
69+
response:{
70+
data:{
71+
error:"Invalid user search query.",
72+
},
73+
},
74+
isAxiosError:true,
75+
}),
76+
).toEqual("")
77+
})
78+
79+
it("no validations field returns empty validation message",()=>{
80+
expect(
81+
getValidationErrorMessage({
82+
response:{
83+
data:{
84+
message:"Invalid user search query.",
85+
detail:`Query element "role:a:e" can only contain 1 ':'`,
86+
},
87+
},
88+
isAxiosError:true,
89+
}),
90+
).toEqual("")
91+
})
92+
})

‎site/src/api/errors.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,15 @@ export const getErrorMessage = (
7171
:errorinstanceofError
7272
?error.message
7373
:defaultMessage
74+
75+
/**
76+
*
77+
*@param error
78+
*@returns a combined validation error message if the error is an ApiError
79+
* and contains validation messages for different form fields.
80+
*/
81+
exportconstgetValidationErrorMessage=(error:Error|ApiError|unknown):string=>{
82+
constvalidationErrors=
83+
isApiError(error)&&error.response.data.validations ?error.response.data.validations :[]
84+
returnvalidationErrors.map((error)=>error.detail).join("\n")
85+
}

‎site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import{ComponentMeta,Story}from"@storybook/react"
2-
import{workspaceFilterQuery}from"../../util/workspace"
2+
import{userFilterQuery,workspaceFilterQuery}from"../../util/filters"
33
import{SearchBarWithFilter,SearchBarWithFilterProps}from"./SearchBarWithFilter"
44

55
exportdefault{
@@ -23,3 +23,26 @@ WithPresetFilters.args = {
2323
{query:"random query",name:"Random query"},
2424
],
2525
}
26+
27+
exportconstWithError=Template.bind({})
28+
WithError.args={
29+
filter:"status:inactive",
30+
presetFilters:[
31+
{query:userFilterQuery.active,name:"Active users"},
32+
{query:"random query",name:"Random query"},
33+
],
34+
error:{
35+
response:{
36+
data:{
37+
message:"Invalid user search query.",
38+
validations:[
39+
{
40+
field:"status",
41+
detail:`Query param "status" has invalid value: "inactive" is not a valid user status`,
42+
},
43+
],
44+
},
45+
},
46+
isAxiosError:true,
47+
},
48+
}

‎site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx

Lines changed: 67 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import TextField from "@material-ui/core/TextField"
88
importSearchIconfrom"@material-ui/icons/Search"
99
import{FormikErrors,useFormik}from"formik"
1010
import{useState}from"react"
11+
import{getValidationErrorMessage}from"../../api/errors"
1112
import{getFormHelpers,onChangeTrimmed}from"../../util/formUtils"
1213
import{CloseDropdown,OpenDropdown}from"../DropdownArrows/DropdownArrows"
1314
import{Stack}from"../Stack/Stack"
@@ -20,6 +21,7 @@ export interface SearchBarWithFilterProps {
2021
filter?:string
2122
onFilter:(query:string)=>void
2223
presetFilters?:PresetFilter[]
24+
error?:unknown
2325
}
2426

2527
exportinterfacePresetFilter{
@@ -37,6 +39,7 @@ export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({
3739
filter,
3840
onFilter,
3941
presetFilters,
42+
error,
4043
})=>{
4144
conststyles=useStyles()
4245

@@ -68,69 +71,76 @@ export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({
6871
handleClose()
6972
}
7073

74+
consterrorMessage=getValidationErrorMessage(error)
75+
7176
return(
72-
<Stackdirection="row"spacing={0}className={styles.filterContainer}>
73-
{presetFilters&&presetFilters.length>0&&(
74-
<Button
75-
aria-controls="filter-menu"
76-
aria-haspopup="true"
77-
onClick={handleClick}
78-
className={styles.buttonRoot}
79-
>
80-
{Language.filterName}{anchorEl ?<CloseDropdown/> :<OpenDropdown/>}
81-
</Button>
82-
)}
83-
84-
<formonSubmit={form.handleSubmit}className={styles.filterForm}>
85-
<TextField
86-
{...getFieldHelpers("query")}
87-
className={styles.textFieldRoot}
88-
onChange={onChangeTrimmed(form)}
89-
fullWidth
90-
variant="outlined"
91-
InputProps={{
92-
startAdornment:(
93-
<InputAdornmentposition="start">
94-
<SearchIconfontSize="small"/>
95-
</InputAdornment>
96-
),
97-
}}
98-
/>
99-
</form>
100-
101-
{presetFilters&&presetFilters.length>0&&(
102-
<Menu
103-
id="filter-menu"
104-
anchorEl={anchorEl}
105-
keepMounted
106-
open={Boolean(anchorEl)}
107-
onClose={handleClose}
108-
TransitionComponent={Fade}
109-
anchorOrigin={{
110-
vertical:"bottom",
111-
horizontal:"left",
112-
}}
113-
transformOrigin={{
114-
vertical:"top",
115-
horizontal:"left",
116-
}}
117-
>
118-
{presetFilters.map((presetFilter)=>(
119-
<MenuItemkey={presetFilter.name}onClick={setPresetFilter(presetFilter.query)}>
120-
{presetFilter.name}
121-
</MenuItem>
122-
))}
123-
</Menu>
124-
)}
77+
<Stackspacing={1}className={styles.root}>
78+
<Stackdirection="row"spacing={0}className={styles.filterContainer}>
79+
{presetFilters&&presetFilters.length>0&&(
80+
<Button
81+
aria-controls="filter-menu"
82+
aria-haspopup="true"
83+
onClick={handleClick}
84+
className={styles.buttonRoot}
85+
>
86+
{Language.filterName}{anchorEl ?<CloseDropdown/> :<OpenDropdown/>}
87+
</Button>
88+
)}
89+
90+
<formonSubmit={form.handleSubmit}className={styles.filterForm}>
91+
<TextField
92+
{...getFieldHelpers("query")}
93+
className={styles.textFieldRoot}
94+
onChange={onChangeTrimmed(form)}
95+
fullWidth
96+
variant="outlined"
97+
InputProps={{
98+
startAdornment:(
99+
<InputAdornmentposition="start">
100+
<SearchIconfontSize="small"/>
101+
</InputAdornment>
102+
),
103+
}}
104+
/>
105+
</form>
106+
107+
{presetFilters&&presetFilters.length>0&&(
108+
<Menu
109+
id="filter-menu"
110+
anchorEl={anchorEl}
111+
keepMounted
112+
open={Boolean(anchorEl)}
113+
onClose={handleClose}
114+
TransitionComponent={Fade}
115+
anchorOrigin={{
116+
vertical:"bottom",
117+
horizontal:"left",
118+
}}
119+
transformOrigin={{
120+
vertical:"top",
121+
horizontal:"left",
122+
}}
123+
>
124+
{presetFilters.map((presetFilter)=>(
125+
<MenuItemkey={presetFilter.name}onClick={setPresetFilter(presetFilter.query)}>
126+
{presetFilter.name}
127+
</MenuItem>
128+
))}
129+
</Menu>
130+
)}
131+
</Stack>
132+
{errorMessage&&<StackclassName={styles.errorRoot}>{errorMessage}</Stack>}
125133
</Stack>
126134
)
127135
}
128136

129137
constuseStyles=makeStyles((theme)=>({
138+
root:{
139+
marginBottom:theme.spacing(2),
140+
},
130141
filterContainer:{
131142
border:`1px solid${theme.palette.divider}`,
132143
borderRadius:theme.shape.borderRadius,
133-
marginBottom:theme.spacing(2),
134144
},
135145
filterForm:{
136146
width:"100%",
@@ -146,4 +156,7 @@ const useStyles = makeStyles((theme) => ({
146156
border:"none",
147157
},
148158
},
159+
errorRoot:{
160+
color:theme.palette.error.dark,
161+
},
149162
}))

‎site/src/components/UsersTable/UsersTable.stories.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,13 @@ Empty.args = {
2828
users:[],
2929
roles:MockSiteRoles,
3030
}
31+
32+
exportconstLoading=Template.bind({})
33+
Loading.args={
34+
users:[],
35+
roles:MockSiteRoles,
36+
isLoading:true,
37+
}
38+
Loading.parameters={
39+
chromatic:{pauseAnimationAtEnd:true},
40+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp