Skip to content

Commit

Permalink
Add gdoc tombstones
Browse files Browse the repository at this point in the history
  • Loading branch information
rakyi committed Sep 19, 2024
1 parent 84a8d05 commit 3bca1c5
Show file tree
Hide file tree
Showing 22 changed files with 548 additions and 104 deletions.
2 changes: 1 addition & 1 deletion _routes.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 1,
"include": ["/grapher/*", "/donation/*"],
"include": ["/grapher/*", "/deleted/*", "/donation/*"],
"exclude": ["/grapher/_grapherRedirects.json"]
}
6 changes: 3 additions & 3 deletions adminSiteClient/GdocsAdd.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { gdocUrlRegex } from "@ourworldindata/utils"
import { GDOCS_URL_PLACEHOLDER, gdocUrlRegex } from "@ourworldindata/utils"
import React from "react"
import {
GDOCS_BASIC_ARTICLE_TEMPLATE_URL,
Expand Down Expand Up @@ -64,12 +64,12 @@ export const GdocsAdd = ({ onAdd }: { onAdd: (id: string) => void }) => {
onChange={(e) => setDocumentUrl(e.target.value)}
value={documentUrl}
required
placeholder="https://docs.google.com/document/d/****/edit"
placeholder={GDOCS_URL_PLACEHOLDER}
pattern={gdocUrlRegex.toString().slice(1, -1)}
/>
<span className="validation-notice">
Invalid URL - it should look like this:{" "}
<pre>https://docs.google.com/document/d/****/edit</pre>
<pre>{GDOCS_URL_PLACEHOLDER}</pre>
</span>
</div>
</div>
Expand Down
192 changes: 158 additions & 34 deletions adminSiteClient/GdocsMoreMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import React from "react"
import { Dropdown, Menu, Button, Modal } from "antd"
import * as React from "react"
import { useState } from "react"
import { Dropdown, Button, Modal, Form, Checkbox, Input } from "antd"
import {
faEllipsisVertical,
faTrash,
faXmark,
faBug,
} from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js"
import { OwidGdoc } from "@ourworldindata/utils"
import {
CreateTombstoneData,
GDOCS_URL_PLACEHOLDER,
gdocUrlRegex,
OwidGdoc,
} from "@ourworldindata/utils"

enum GdocsMoreMenuAction {
Debug = "debug",
Expand All @@ -24,8 +30,10 @@ export const GdocsMoreMenu = ({
gdoc: OwidGdoc
onDebug: VoidFunction
onUnpublish: VoidFunction
onDelete: VoidFunction
onDelete: (tombstone?: CreateTombstoneData) => void
}) => {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)

const confirmUnpublish = () => {
Modal.confirm({
title: "Are you sure you want to unpublish this article?",
Expand All @@ -39,26 +47,13 @@ export const GdocsMoreMenu = ({
maskClosable: true,
})
}
const confirmDelete = () => {
Modal.confirm({
title: "Are you sure you want to delete this article?",
content:
"The article will be removed from the admin list and unpublished. The original Google Doc will be preserved.",
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk() {
onDelete()
},
maskClosable: true,
})
}

return (
<Dropdown
trigger={["click"]}
overlay={
<Menu
onClick={({ key }) => {
<>
<Dropdown
trigger={["click"]}
menu={{
onClick: ({ key }) => {
switch (key) {
case GdocsMoreMenuAction.Debug:
onDebug()
Expand All @@ -67,11 +62,11 @@ export const GdocsMoreMenu = ({
confirmUnpublish()
break
case GdocsMoreMenuAction.Delete:
confirmDelete()
setIsDeleteModalOpen(true)
break
}
}}
items={[
},
items: [
{
key: GdocsMoreMenuAction.Debug,
label: "Debug",
Expand All @@ -90,14 +85,143 @@ export const GdocsMoreMenu = ({
danger: true,
icon: <FontAwesomeIcon icon={faTrash} />,
},
]}
/>
}
placement="bottomRight"
],
}}
placement="bottomRight"
>
<Button>
<FontAwesomeIcon icon={faEllipsisVertical} />
</Button>
</Dropdown>
<DeleteModal
gdoc={gdoc}
isOpen={isDeleteModalOpen}
setIsOpen={setIsDeleteModalOpen}
onOk={onDelete}
/>
</>
)
}

type DeleteFields = {
shouldCreateTombstone: boolean
reason?: string
relatedLink?: string
}

function DeleteModal({
gdoc,
isOpen,
setIsOpen,
onOk,
}: {
gdoc: OwidGdoc
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
onOk: (tombstone?: CreateTombstoneData) => void
}) {
// We need to keep track of this state ourselves because antd form data
// isn't reactive.
const [shouldCreateTombstone, setShouldCreateTombstone] = useState(false)

function handleOnFinish({ reason, relatedLink }: DeleteFields) {
const tombstone = shouldCreateTombstone
? { reason, relatedLink }
: undefined
onOk(tombstone)
setIsOpen(false)
}

return (
<Modal
open={isOpen}
title="Are you sure you want to delete this article?"
okText="Delete"
okType="danger"
okButtonProps={{
htmlType: "submit",
// Note: antd makes it really hard/impossible to correctly
// disable the button based on the form state, so we don't
// use the disabled prop.
}}
cancelText="Cancel"
onCancel={() => setIsOpen(false)}
destroyOnClose
// https://ant.design/components/form#why-is-there-a-form-warning-when-used-in-modal
forceRender
// Render the ok submit button inside the form.
modalRender={(node) => (
<Form<DeleteFields>
layout="vertical"
requiredMark="optional"
clearOnDestroy
onFinish={handleOnFinish}
>
{node}
</Form>
)}
>
<Button>
<FontAwesomeIcon icon={faEllipsisVertical} />
</Button>
</Dropdown>
<>
<p>
The article will be removed from the admin list and
unpublished. The original Google Doc will be preserved.
</p>
{gdoc.published && (
<Form.Item
name="shouldCreateTombstone"
extra={
<>
If checked, the article will be redirected to a
custom Not Found (404) page at{" "}
<code>/deleted/{gdoc.slug}</code>.
</>
}
>
<Checkbox
checked={shouldCreateTombstone}
onChange={(e) => {
setShouldCreateTombstone(e.target.checked)
}}
>
Create tombstone page
</Checkbox>
</Form.Item>
)}
{shouldCreateTombstone && (
<>
<Form.Item
name="reason"
label="Reason for removing the article"
extra="Will use a generic default message if left blank."
>
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item
name="relatedLink"
label="Related link"
rules={[
{
pattern: gdocUrlRegex,
message: (
<>
Invalid Google Doc URL. It should
look like this:
<br />
{GDOCS_URL_PLACEHOLDER}
</>
),
},
]}
extra="Point user to a related page to help them find what they need."
>
<Input
type="url"
placeholder={GDOCS_URL_PLACEHOLDER}
/>
</Form.Item>
</>
)}
</>
</Modal>
)
}
5 changes: 3 additions & 2 deletions adminSiteClient/GdocsPreviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
OwidGdocType,
OwidGdoc,
Tippy,
CreateTombstoneData,
} from "@ourworldindata/utils"
import { Button, Col, Drawer, Row, Space, Tag, Typography } from "antd"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js"
Expand Down Expand Up @@ -170,9 +171,9 @@ export const GdocsPreviewPage = ({ match, history }: GdocsMatchProps) => {
openSuccessNotification("unpublished")
}

const onDelete = async () => {
const onDelete = async (tombstone?: CreateTombstoneData) => {
if (!currentGdoc) return
await store.delete(currentGdoc)
await store.delete(currentGdoc, tombstone)
history.push("/gdocs")
}

Expand Down
10 changes: 7 additions & 3 deletions adminSiteClient/GdocsStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
} from "@ourworldindata/utils"
import { AdminAppContext } from "./AdminAppContext.js"
import { Admin } from "./Admin.js"
import { extractGdocIndexItem } from "@ourworldindata/types"
import {
CreateTombstoneData,
extractGdocIndexItem,
} from "@ourworldindata/types"

/**
* This was originally a MobX data domain store (see
Expand Down Expand Up @@ -62,8 +65,9 @@ export class GdocsStore {
}

@action
async delete(gdoc: OwidGdoc) {
await this.admin.requestJSON(`/api/gdocs/${gdoc.id}`, {}, "DELETE")
async delete(gdoc: OwidGdoc, tombstone?: CreateTombstoneData) {
const body = tombstone ? { tombstone } : {}
await this.admin.requestJSON(`/api/gdocs/${gdoc.id}`, body, "DELETE")
}

@action
Expand Down
22 changes: 17 additions & 5 deletions adminSiteServer/apiRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2935,6 +2935,19 @@ deleteRouteWithRWTransaction(apiRouter, "/gdocs/:id", async (req, res, trx) => {
const gdoc = await getGdocBaseObjectById(trx, id, false)
if (!gdoc) throw new JsonError(`No Google Doc with id ${id} found`)

const gdocSlug = getCanonicalUrl("", gdoc)
const { tombstone } = req.body

if (tombstone) {
const slug = gdocSlug.replace("/", "")
await trx
.table("posts_gdocs_tombstones")
.insert({ ...tombstone, gdocId: id, slug })
await trx
.table("redirects")
.insert({ source: gdocSlug, target: `/deleted${gdocSlug}` })
}

await trx
.table("posts")
.where({ gdocSuccessorId: gdoc.id })
Expand All @@ -2946,12 +2959,11 @@ deleteRouteWithRWTransaction(apiRouter, "/gdocs/:id", async (req, res, trx) => {
if (gdoc.published && checkIsGdocPostExcludingFragments(gdoc)) {
await removeIndividualGdocPostFromIndex(gdoc)
}
// Assets have TTL of one week in Cloudflare. Add a redirect to make sure
// the page is no longer accessible.
// https://developers.cloudflare.com/pages/configuration/serving-pages/#asset-retention
const gdocSlug = getCanonicalUrl("", gdoc)
if (gdoc.published) {
if (gdocSlug && gdocSlug !== "/") {
if (!tombstone && gdocSlug && gdocSlug !== "/") {
// Assets have TTL of one week in Cloudflare. Add a redirect to make sure
// the page is no longer accessible.
// https://developers.cloudflare.com/pages/configuration/serving-pages/#asset-retention
console.log(`Creating redirect for "${gdocSlug}" to "/"`)
await db.knexRawInsert(
trx,
Expand Down
Loading

0 comments on commit 3bca1c5

Please sign in to comment.