Skip to content

Commit

Permalink
Add admin for multidimensional data pages
Browse files Browse the repository at this point in the history
  • Loading branch information
rakyi committed Feb 7, 2025
1 parent a497ef4 commit f158ace
Show file tree
Hide file tree
Showing 18 changed files with 884 additions and 338 deletions.
544 changes: 289 additions & 255 deletions adminSiteClient/AdminApp.tsx

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions adminSiteClient/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from "react"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js"
import {
faChartBar,
faChartLine,
faFile,
faTable,
faSkullCrossbones,
Expand Down Expand Up @@ -38,6 +39,11 @@ export const AdminSidebar = (): React.ReactElement => (
<FontAwesomeIcon icon={faPanorama} /> Narrative charts
</Link>
</li>
<li>
<Link to="/multi-dims">
<FontAwesomeIcon icon={faChartLine} /> Multi-dims
</Link>
</li>
<li>
<Link to="/posts">
<FontAwesomeIcon icon={faFile} /> Posts
Expand Down
5 changes: 2 additions & 3 deletions adminSiteClient/ChartViewIndexPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react"
import * as React from "react"
import { Button, Flex, Input, Space, Table } from "antd"
import { Button, Flex, Input, Space, Table, TableColumnsType } from "antd"

import { AdminLayout } from "./AdminLayout.js"
import { AdminAppContext } from "./AdminAppContext.js"
import { Timeago } from "./Forms.js"
import { ColumnsType } from "antd/es/table/InternalTable.js"
import { ApiChartViewOverview } from "../adminShared/AdminTypes.js"
import { GRAPHER_DYNAMIC_THUMBNAIL_URL } from "../settings/clientSettings.js"
import { Link } from "./Link.js"
Expand All @@ -20,7 +19,7 @@ function createColumns(ctx: {
text: string | null | undefined
) => React.ReactElement | string
deleteFn: (chartViewId: number) => void
}): ColumnsType<ApiChartViewOverview> {
}): TableColumnsType<ApiChartViewOverview> {
return [
{
title: "Preview",
Expand Down
4 changes: 2 additions & 2 deletions adminSiteClient/ImagesIndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import {
Popconfirm,
Popover,
Table,
TableColumnsType,
Upload,
notification,
} from "antd"
import { AdminLayout } from "./AdminLayout.js"
import { AdminAppContext } from "./AdminAppContext.js"
import { DbEnrichedImageWithUserId, DbPlainUser } from "@ourworldindata/types"
import { Timeago } from "./Forms.js"
import { ColumnsType } from "antd/es/table/InternalTable.js"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import {
faClose,
Expand Down Expand Up @@ -246,7 +246,7 @@ function createColumns({
users: UserMap
usage: Dictionary<UsageInfo[]>
notificationApi: NotificationInstance
}): ColumnsType<DbEnrichedImageWithUserId> {
}): TableColumnsType<DbEnrichedImageWithUserId> {
return [
{
title: "Preview",
Expand Down
282 changes: 282 additions & 0 deletions adminSiteClient/MultiDimIndexPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import {
useMutation,
UseMutationResult,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import { createContext, useContext, useEffect, useMemo, useState } from "react"
import {
Input,
Popconfirm,
Switch,
Table,
TableColumnsType,
Typography,
notification,
} from "antd"
import { Admin } from "./Admin.js"
import { AdminLayout } from "./AdminLayout.js"
import { AdminAppContext } from "./AdminAppContext.js"
import urljoin from "url-join"
import {
BAKED_BASE_URL,
BAKED_GRAPHER_URL,
GRAPHER_DYNAMIC_THUMBNAIL_URL,
} from "../settings/clientSettings.js"
import { dayjs, Json } from "@ourworldindata/utils"
import { Link } from "./Link.js"

type ApiMultiDim = {
id: number
title: string
slug: string
updatedAt: string
published: boolean
}

type MultiDim = Omit<ApiMultiDim, "updatedAt"> & {
updatedAt: Date
}

function SlugField({
id,
slug,
slugMutation,
}: {
id: number
slug: string
slugMutation: UseMutationResult<
unknown,
unknown,
{ id: number; slug: string },
unknown
>
}) {
function handleOnChange(value: string) {
if (value !== slug) slugMutation.mutate({ id, slug: value })
}

return (
<Typography.Text editable={{ onChange: handleOnChange }}>
{slug}
</Typography.Text>
)
}

function createColumns(
slugMutation: UseMutationResult<
unknown,
unknown,
{ id: number; slug: string },
unknown
>,
publishMutation: UseMutationResult<
unknown,
unknown,
{ id: number; published: boolean },
unknown
>
): TableColumnsType<MultiDim> {
return [
{
title: "Preview",
dataIndex: "thumbnailUrl",
width: 100,
key: "thumbnail",
render: (_, record) => {
return (
<a
href={urljoin(
BAKED_BASE_URL,
`/admin/grapher/${record.slug}`
)}
target="_blank"
rel="noopener"
>
<img
src={urljoin(
GRAPHER_DYNAMIC_THUMBNAIL_URL,
`/${record.slug}.png?imHeight=400`
)}
style={{ height: "140px", width: "auto" }}
alt="Preview"
/>
</a>
)
},
},
{
title: "Title",
dataIndex: "title",
key: "title",
width: 280,
render: (text, record) =>
record.published ? (
<a
href={urljoin(BAKED_GRAPHER_URL, record.slug)}
target="_blank"
rel="noopener"
>
{text}
</a>
) : (
text
),
sorter: (a, b) => a.title.localeCompare(b.title),
},
{
title: "Slug",
dataIndex: "slug",
key: "slug",
render: (slug, { id }) => (
<SlugField id={id} slug={slug} slugMutation={slugMutation} />
),
sorter: (a, b) => a.title.localeCompare(b.title),
},
{
title: "Last updated",
dataIndex: "updatedAt",
key: "updatedAt",
defaultSortOrder: "descend",
render: (updatedAt) => {
const date = dayjs(updatedAt)
return <span title={date.format()}>{date.fromNow()}</span>
},
sorter: (a, b) => a.updatedAt.getTime() - b.updatedAt.getTime(),
},
{
title: "Published",
dataIndex: "published",
key: "published",
align: "center",
render: (published, record) => (
<Popconfirm
title={`Are you sure you want to ${published ? "unpublish" : "publish"} this page?`}
onConfirm={() =>
publishMutation.mutate({
id: record.id,
published: !published,
})
}
okText="Yes"
cancelText="No"
>
<Switch
checked={published}
disabled={
publishMutation.isLoading &&
publishMutation.variables?.id === record.id
}
/>
</Popconfirm>
),
sorter: (a, b) => Number(b.published) - Number(a.published),
},
]
}

function deserializeMultiDim(mdim: ApiMultiDim): MultiDim {
return {
...mdim,
updatedAt: new Date(mdim.updatedAt),
}
}

async function fetchMultiDims(admin: Admin) {
const { multiDims } = await admin.getJSON<{ multiDims: ApiMultiDim[] }>(
"/api/multi-dims.json"
)
return multiDims.map(deserializeMultiDim)
}

async function patchMultiDim(admin: Admin, id: number, data: Json) {
const { multiDim } = await admin.requestJSON<{ multiDim: ApiMultiDim }>(
`/api/multi-dims/${id}`,
data,
"PATCH"
)
return deserializeMultiDim(multiDim)
}

const NotificationContext = createContext(null)

export function MultiDimIndexPage() {
const { admin } = useContext(AdminAppContext)
const [notificationApi, notificationContextHolder] =
notification.useNotification()
const [search, setSearch] = useState("")
const queryClient = useQueryClient()

useEffect(() => {
admin.loadingIndicatorSetting = "off"
return () => {
admin.loadingIndicatorSetting = "default"
}
}, [admin])

const { data } = useQuery({
queryKey: ["multiDims"],
queryFn: () => fetchMultiDims(admin),
})

const publishMutation = useMutation({
mutationFn: ({ id, published }: { id: number; published: boolean }) =>
patchMultiDim(admin, id, { published }),
onSuccess: async ({ published }) => {
notificationApi.info({
message: published ? "Published" : "Unpublished",
description: <Link to="/deploys">Check deploy progress</Link>,
placement: "bottomRight",
})
return queryClient.invalidateQueries({ queryKey: ["multiDims"] })
},
})

const slugMutation = useMutation({
mutationFn: ({ id, slug }: { id: number; slug: string }) =>
patchMultiDim(admin, id, { slug }),
onSuccess: async ({ published }) => {
notificationApi.info({
message: "Slug updated",
description: published ? (
<Link to="/deploys">Check deploy progress</Link>
) : undefined,
placement: "bottomRight",
})
return queryClient.invalidateQueries({ queryKey: ["multiDims"] })
},
})

const filteredMdims = useMemo(() => {
const query = search.trim().toLowerCase()
return data?.filter((mdim) =>
[mdim.title, mdim.slug].some((field) =>
field.toLowerCase().includes(query)
)
)
}, [data, search])

const columns = createColumns(slugMutation, publishMutation)

return (
<AdminLayout title="Multidimensional Data Pages">
<NotificationContext.Provider value={null}>
{notificationContextHolder}
<main>
<Input
placeholder="Search by title or slug"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 500, marginBottom: 20 }}
autoFocus
/>
<Table
columns={columns}
dataSource={filteredMdims}
rowKey={(x) => x.id}
/>
</main>
</NotificationContext.Provider>
</AdminLayout>
)
}
14 changes: 8 additions & 6 deletions adminSiteServer/apiRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import {
deleteImageHandler,
getImageUsageHandler,
} from "./apiRoutes/images.js"
import { handleMultiDimDataPageRequest } from "./apiRoutes/mdims.js"
import {
handlePutMultiDim,
handleGetMultiDims,
handlePatchMultiDim,
} from "./apiRoutes/mdims.js"
import {
fetchAllWork,
fetchNamespaces,
Expand Down Expand Up @@ -264,11 +268,9 @@ deleteRouteWithRWTransaction(apiRouter, "/images/:id", deleteImageHandler)
getRouteWithROTransaction(apiRouter, "/images/usage", getImageUsageHandler)

// Mdim routes
putRouteWithRWTransaction(
apiRouter,
"/multi-dim/:slug",
handleMultiDimDataPageRequest
)
getRouteWithROTransaction(apiRouter, "/multi-dims.json", handleGetMultiDims)
putRouteWithRWTransaction(apiRouter, "/multi-dim/:slug", handlePutMultiDim)
patchRouteWithRWTransaction(apiRouter, "/multi-dims/:id", handlePatchMultiDim)

// Misc routes
getRouteWithROTransaction(apiRouter, "/all-work", fetchAllWork)
Expand Down
Loading

0 comments on commit f158ace

Please sign in to comment.