はじめに
前回はポートフォリオを大改造してデータをデータベースで管理するようにしました。
今回は、前回やり残した「プロジェクトの並び替え」部分を実装した話を記そうと思います。
抱えていた問題
前回ポートフォリオのデータをデータベースで管理する方向性にシフトしたのですが、DB設計の段階で考え漏れていたことがありました。それは「プロジェクトの並び替え」です。
プロジェクトは現状、写真のようにスターがついたものが上に来るようになっているのですが、Starがついたプロジェクト同士 / Startがついていないプロジェクト同士の順序については操作ができない設計になっていました。
そこで今回は、順序を保存するカラムを追加し、UI上でプロジェクトの並び替えができるようにしていきたいと思います。
DB設計
順序をデータベース上でどう保存にするかについては、次の3つの方法を案として考えました。
- レコードに自身の順番(position)をもたせる
- レコードに自身の優先度(priority)をもたせる
- レコードに自身の順番を大きな整数でもたせる
各方法についてのメリット・デメリットは以下のようになります。
方式 | メリット | デメリット |
---|---|---|
1 | 実装がシンプル | 並び替えのたびに全てのデータを更新する必要がある |
2 | 並び替え時に大量のデータを更新しなくていい | priorityをすべて手打ちで入力しないといけない |
3 | 並び替え時に大量のデータを更新しなくてもいい・並び替えをUIで実装可能 | 数十回ほど並び替えを繰り返すだけで破綻する |
はじめは3の方法がいいと思っていたのですが、positionの値を100,000おきに設定していたとしても、並び替えごとにpositionの値の差は半分になっていきます。
例)
id | position |
---|---|
1 | 100,000 |
2 | 200,000 |
3 | 300,000 |
↓
id | position |
---|---|
1 | 100,000 |
3 | 150,000 |
2 | 200,000 |
↓
id | position |
---|---|
1 | 100,000 |
2 | 125,000 |
3 | 1500,000 |
これを繰り返していくと、ものの数十回ほどでレコード1とレコード2のpositionが同じ値になってしまいます。
そうなったときに、positionの値はどう割り振り直すのか?などを考えると実装が複雑になってしまいます。
今回の場合は、
- 並び替えは私の気分次第で割と頻繁に行う
- レコードの数は高々数十個程度
- 並び替えはUIを使って手軽に直感的に行えるようにしたい
ということから、1のシンプルな実装にすることにしました。
フォームのUI
DBの設計が決まったので、次は並び替えのUIをどう作っていくかを検討します。
検討したライブラリは次の4つです。
それぞれのnpm trendsは写真のとおりでした。
1年前はreact-draggable
が最も使われていましたが、徐々に減少してきており、最近では@dnd-kit/core
が伸びてきていることが読み取れます。
それぞれのスター数・メンテナンス頻度・書きやすさ(主観)は次のとおりでした。
ライブラリ | スター数 | メンテ頻度 | 書きやすさ |
---|---|---|---|
react-dnd | 20.2k | 1年前 | - |
react-beautiful-dnd | 31.8k | 7ヶ月前 | - |
react-draggable | 8.7k | 4ヶ月前 | ○ |
dnd-kit | 10.5k | 2ヶ月前 | ○ |
react-dnd
はここ1年メンテナンスがされておらず、react-beautiful-dnd
はメンテナンスが当面終了と記載されていたので、候補から除外しました。
react-draggable
とdnd-kit
はどちらも良さそうでしたが、
- 日本語の参考サイトの豊富さ
- スター数
- メンテナンス頻度
の観点からdnd-kit
を採用することにしました。
実装
バックエンド
まず、Graphqlのスキーマは次のようにしました。
idのリストをinputで受け取り、その順序に従ってprojectのpositionを変更するという仕様で、内部のposition等のプロパティは外部で意識しなくてもいいようにしました。
input UpdateProjectOrderInput {
ids: [String!]!
}
extend type Mutation {
updateProjectOrder(input: UpdateProjectOrderInput!): [Project!]!
}
実際の処理部分は次のようになっています(読みやすさのためにエラー処理のコードは省略しています)。
入力のids
に含まれていないプロジェクトの扱いをどうするかは迷ったのですが、指定されていないプロジェクトは順序を保ったまま後ろにずらすという仕様にしました。
position
のデフォルト値は0にして、デフォルトで1番上に来るようにしたいため、指定するpositionの値は1以上になるようにしました。
allProjects, err := u.repo.List()
newProjects := make([]*domain.Project, len(input.Ids))
for _, project := range allProjects {
pos := slices.Index(input.Ids, project.ID)
projectInput := project.ToInput()
if pos < 0 {
newPos := project.Position + len(input.Ids)
projectInput.Position = &newPos
} else {
newPos := pos + 1
projectInput.Position = &newPos
}
newProject, err := u.repo.Update(projectInput)
if pos >= 0 {
newProjects[pos] = newProject
}
}
return newProjects, nil
フロントエンド
フロントエンドのコードは概ねドキュメントの通りです。
restrictToVerticalAxis
modifierを使うことで縦方向にしかドラッグできないようにしたり、順序を変えたあとはSaveするまで他の操作が出来ないようにしたりといった細かな工夫を施しています。
コード
export const Projects = (): JSX.Element => {
const context = useMemo(() => ({ additionalTypenames: ['Project'] }), [])
const [{ data, error }] = useGetProjectsQuery({ context })
const [, deleteProject] = useDeleteProjectMutation()
const [projects, setProjects] = useState<ProjectType[]>()
const [, updateProjectOrder] = useUpdateProjectOrderMutation()
const [orderChanged, setOrderChanged] = useState(false)
const onDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
setProjects((projects) => {
if (!projects) return
const ids = projects.map((project) => project.id)
const oldIndex = ids.indexOf(active.id as string)
const newIndex = ids.indexOf(over.id as string)
return arrayMove(projects, oldIndex, newIndex)
})
setOrderChanged(true)
}
}, [])
const saveOrder = useCallback(async () => {
if (!projects) return
const ids = projects.map((project) => project.id)
const { error } = await updateProjectOrder({ input: { ids } })
if (error) {
console.error(error)
return
}
setOrderChanged(false)
}, [projects, updateProjectOrder])
const resetOrder = useCallback(() => {
if (!data) return
setProjects(data.projects)
setOrderChanged(false)
}, [data])
useEffect(() => {
if (!data) return
setProjects(data.projects)
}, [data])
if (error) return <Error statusCode={400} />
if (!projects) return <>loading...</>
return (
<Box>
<Typography variant="h4" component="h2">
Projects
</Typography>
<Box mt={2}>
<Button
disabled={orderChanged}
component={Link}
href="/projects/new"
fullWidth
variant="contained"
>
新規作成
</Button>
</Box>
<Box mt={2}>
<Button disabled={!orderChanged} variant="outlined" onClick={saveOrder}>
Save Order
</Button>
<Button
sx={{ marginLeft: 1 }}
variant="outlined"
disabled={!orderChanged}
onClick={resetOrder}
>
Reset Order
</Button>
</Box>
<DndContext
collisionDetection={closestCenter}
modifiers={[
restrictToVerticalAxis,
restrictToParentElement,
restrictToParentElement,
]}
onDragEnd={onDragEnd}
>
<SortableContext
strategy={verticalListSortingStrategy}
items={projects}
>
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell>Id</TableCell>
<TableCell>Title</TableCell>
<TableCell>Description</TableCell>
<TableCell>IsFavorite</TableCell>
<TableCell>Position</TableCell>
<TableCell>CreatedAt</TableCell>
<TableCell>UpdatedAt</TableCell>
<TableCell>Links</TableCell>
</TableRow>
</TableHead>
<TableBody>
{projects.map((project) => (
<ProjectItem
actionsDisabled={orderChanged}
key={project.id}
project={project}
deleteProject={() => {
void deleteProject({ id: project.id })
}}
/>
))}
</TableBody>
</Table>
</SortableContext>
</DndContext>
</Box>
)
}
type ProjectItemProps = {
project: ProjectType
deleteProject: () => void
actionsDisabled?: boolean
}
const ProjectItem = ({
project,
deleteProject,
actionsDisabled = false,
}: ProjectItemProps) => {
const { listeners, setNodeRef, transform, transition, attributes } =
useSortable({
id: project.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<TableRow ref={setNodeRef} style={style} {...attributes}>
<TableCell>
<IconButton {...listeners}>
<DragHandleIcon />
</IconButton>
</TableCell>
<TableCell>{project.id}</TableCell>
<TableCell>{project.title}</TableCell>
<TableCell>{project.description}</TableCell>
<TableCell>{project.isFavorite ? 'true' : 'false'}</TableCell>
<TableCell>{project.position}</TableCell>
<TableCell>{dateLabel(project.createdAt)}</TableCell>
<TableCell>{dateLabel(project.updatedAt)}</TableCell>
<TableCell
sx={{
'> *': {
marginLeft: 1,
marginTop: 1,
},
}}
>
<Button
disabled={actionsDisabled}
variant="contained"
component={Link}
size="small"
href={`/projects/${project.id}/edit`}
>
編集
</Button>
<Button
variant="contained"
disabled={actionsDisabled}
size="small"
onClick={deleteProject}
>
削除
</Button>
</TableCell>
</TableRow>
)
}
実際の挙動はこんな感じです。滑らかに動いてくれていて満足しています。
最後に
今回はプロジェクトの並び替えができるようDB・UIを整えました。順序を保存するようなテーブルの設計をするのは初めてだったので、良い学びになりました。
UIについても、最終的にかなり直感的に操作できるUIができて満足しています。
次回はお家kubernetes作りに戻ってログ周りを整理していきたいと思います。
参考資料