-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathSearchForm.tsx
277 lines (266 loc) · 9.88 KB
/
SearchForm.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
import type { SubmissionResult } from "@conform-to/react";
import { getFormProps, getInputProps } from "@conform-to/react";
import {
Autocomplete,
MultiSelect,
Stack,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Form, useNavigation } from "react-router";
import { FormError } from "../../../components/FormError";
import { DateRangePicker } from "../../../components/input/DateRangePicker";
import { TextInput } from "../../../components/mantine/TextInput";
import { SubmitButton } from "../../../components/SubmitButton";
import { mantineInputOrder } from "../../../config/mantine";
import type { Topic } from "../../../generated/api/schemas";
import { useMultiSelectInputControl } from "../../../hooks/useMultiSelectInputControl";
import { containsNonNullValues } from "../../../utils/array";
import { safeDateFromUnixMs } from "../../../utils/date";
import { LANGUAGE_ID_TO_LABEL } from "../language";
import type { NoteSearchParams } from "../types";
import { useSimpleNoteSearchForm } from "../useForm";
import { useLanguageLiteral } from "../useLanguageLiteral";
import { AdvancedSearchForm } from "./AdvancedSearchForm";
import { AdvancedSearchModal } from "./AdvancedSearchModal";
import { LanguageSelect } from "./input/LanguageSelect";
import { TopicSelect } from "./input/TopicSelect";
type SearchFormProps = {
defaultValue?: NoteSearchParams;
lastResult?: SubmissionResult<string[]>;
topics: Topic[];
};
export const SearchForm = (props: SearchFormProps) => {
const { defaultValue, lastResult, topics } = props;
const [
mountAdvancedSearchModal,
{ open: openAdvancedSearch, close: closeAdvancedSearch },
] = useDisclosure(false);
const shortLanguage = useLanguageLiteral("ja");
const navigation = useNavigation();
const searchInProgress = navigation.state !== "idle";
const [form, fields] = useSimpleNoteSearchForm({
lastResult,
defaultValue,
});
// 簡易検索画面においても詳細検索にしかない検索条件の値は保持される。
// このため、簡易検索の条件を入れて検索したつもりでも、詳細検索にしかない条件が追加で入る可能性がある。
// これは、詳細検索を多用するユーザーには利用しやすいが、簡易検索を多用するユーザーには予期せぬ挙動となる。
// この問題を解決するために、詳細検索のみの条件が指定されている場合はその数をユーザーにフィードバックしたい。
const hiddenInputKeys = Object.keys(form.value ?? {}).filter((key) => {
return !(
[
"note_includes_text",
"topic_ids",
"language",
"note_created_at_from",
"note_created_at_to",
"limit",
"offset", // フォームでは入力しないが、ページネーションは簡易検索の画面に表示されているので
] satisfies Array<keyof NoteSearchParams>
).includes(
// @ts-expect-error ここでは型が合わないが、ランタイムの挙動は Literal[] と string の比較になるので問題ないstring
key,
);
});
const { value: xUserNamesValue } = useMultiSelectInputControl({
field: fields.x_user_names,
convertFormValueToMantine(formValue) {
if (formValue == null) {
return [];
}
if (typeof formValue === "string") {
return formValue.split(",");
}
return formValue.filter((v) => v != null);
},
convertMantineValueToForm(mantineValue) {
return mantineValue.join(",");
},
});
const { value: noteStatusValue } = useMultiSelectInputControl({
field: fields.note_status,
convertFormValueToMantine(formValue) {
if (formValue == null) {
return [];
}
if (typeof formValue === "string") {
return formValue.split(",");
}
return formValue.filter((v) => v != null);
},
convertMantineValueToForm(mantineValue) {
return mantineValue.join(",");
},
});
return (
<>
<Form method="POST" preventScrollReset {...getFormProps(form)}>
<Stack>
<TextInput
autoComplete="off"
disabled={searchInProgress}
error={
containsNonNullValues(fields.note_includes_text.errors) && (
<FormError errors={[fields.note_includes_text.errors]} />
)
}
label="コミュニティノートに含まれるテキスト"
{...getInputProps(fields.note_includes_text, { type: "text" })}
/>
<TopicSelect
currentLanguage={shortLanguage}
disabled={searchInProgress}
field={fields.topic_ids}
topics={topics}
/>
<LanguageSelect
disabled={searchInProgress}
field={fields.language}
label="言語"
languages={LANGUAGE_ID_TO_LABEL}
/>
<DateRangePicker
convertFormValueToMantine={safeDateFromUnixMs}
convertMantineValueToForm={(date) => date?.valueOf().toString()}
disabled={searchInProgress}
fromField={fields.note_created_at_from}
label="コミュニティノートの作成期間"
toField={fields.note_created_at_to}
valueFormat="YYYY.MM.DD (ddd)"
/>
<Autocomplete
data={["10", "20", "50", "100"]}
description="80: 1ページに最大 80 件のコミュニティノートを表示"
disabled={searchInProgress}
error={
containsNonNullValues(fields.limit.errors) && (
<FormError errors={[fields.limit.errors]} />
)
}
errorProps={{ component: "div" }}
inputWrapperOrder={mantineInputOrder}
label="1ページあたりの表示件数"
{...getInputProps(fields.limit, { type: "number" })}
/>
{/**
* 詳細入力のみに存在する input の条件が入った状態で簡易検索を submit すると詳細入力側の検索条件が消えてしまう!
* ここで不可視 input に値を保持して整合性を保つ
*/}
<>
<input
value={fields.note_excludes_text.value}
{...getInputProps(fields.note_excludes_text, {
type: "hidden",
value: false,
})}
/>
<input
value={fields.post_includes_text.value}
{...getInputProps(fields.post_includes_text, {
type: "hidden",
value: false,
})}
/>
<input
value={fields.post_excludes_text.value}
{...getInputProps(fields.post_excludes_text, {
type: "hidden",
value: false,
})}
/>
<input
value={fields.note_created_at_from.value}
{...getInputProps(fields.note_created_at_from, {
type: "hidden",
value: false,
})}
/>
<MultiSelect
aria-hidden
className="hidden"
name={fields.note_status.name}
value={noteStatusValue}
/>
<MultiSelect
aria-hidden
className="hidden"
name={fields.x_user_names.name}
value={xUserNamesValue}
/>
<input
value={fields.x_user_followers_count_from.value}
{...getInputProps(fields.x_user_followers_count_from, {
type: "hidden",
value: false,
})}
/>
<input
value={fields.x_user_follow_count_from.value}
{...getInputProps(fields.x_user_follow_count_from, {
type: "hidden",
value: false,
})}
/>
<input
value={fields.post_impression_count_from.value}
{...getInputProps(fields.post_impression_count_from, {
type: "hidden",
value: false,
})}
/>
<input
value={fields.post_like_count_from.value}
{...getInputProps(fields.post_like_count_from, {
type: "hidden",
value: false,
})}
/>
<input
value={fields.post_repost_count_from.value}
{...getInputProps(fields.post_repost_count_from, {
type: "hidden",
value: false,
})}
/>
</>
<div className="flex flex-col-reverse gap-y-4 md:flex-col">
{/* 最後の入力の直後は必ず submit ボタンにフォーカスが当たるようにするために、
DOM の順序は固定して flex direction で並べ替える
*/}
<SubmitButton
color="pink"
disabled={!form.valid || searchInProgress}
loading={searchInProgress}
>
検索
</SubmitButton>
<UnstyledButton
c="pink"
className="ms-auto me-0"
onClick={openAdvancedSearch}
type="button"
>
<span className="text-sm">
詳細検索
{hiddenInputKeys.length > 0 &&
` (選択済み条件: ${hiddenInputKeys.length}種類)`}
</span>
</UnstyledButton>
</div>
</Stack>
</Form>
<AdvancedSearchModal
onClose={closeAdvancedSearch}
opened={mountAdvancedSearchModal}
>
<AdvancedSearchForm
defaultValue={defaultValue}
lastResult={lastResult}
onSubmit={closeAdvancedSearch}
topics={topics}
/>
</AdvancedSearchModal>
</>
);
};