Skip to content

Commit

Permalink
Delete dialog
Browse files Browse the repository at this point in the history
  • Loading branch information
ericvicenti committed Nov 6, 2024
1 parent 3ffa8ac commit 0a4f2b5
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 111 deletions.
120 changes: 50 additions & 70 deletions frontend/apps/desktop/src/components/delete-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
import {roleCanWrite, useMyCapability} from '@/models/access-control'
import {useDeleteKey} from '@/models/daemon'
import {zodResolver} from '@hookform/resolvers/zod'
import {HYPERMEDIA_ENTITY_TYPES, unpackHmId} from '@shm/shared'
import {useListSite} from '@/models/documents'
import {hmId, HYPERMEDIA_ENTITY_TYPES, UnpackedHypermediaId} from '@shm/shared'
import {
AlertDialog,
AlertDialogContentProps,
AlertDialogProps,
Button,
Form,
HeadingProps,
ParagraphProps,
Spinner,
XStack,
XStackProps,
YStack,
} from '@shm/ui'
import {ReactNode, useEffect} from 'react'
import {SubmitHandler, useForm} from 'react-hook-form'
import {z} from 'zod'
import {useDeleteEntity} from '../models/entities'
import {ReactNode} from 'react'
import {useDeleteEntities} from '../models/entities'
import {useAppDialog} from './dialog'
import {FormTextArea} from './form-input'
import {FormField} from './forms'

export type DeleteDialogProps = AlertDialogProps & {
dialogContentProps?: AlertDialogContentProps
Expand All @@ -34,11 +31,6 @@ export type DeleteDialogProps = AlertDialogProps & {
descriptionProps?: ParagraphProps
}

const deleteFormSchema = z.object({
description: z.string(),
})
type DeleteFormFields = z.infer<typeof deleteFormSchema>

export function useDeleteDialog() {
return useAppDialog(DeleteEntityDialog, {isAlert: true})
}
Expand All @@ -47,73 +39,61 @@ export function DeleteEntityDialog({
input: {id, title, onSuccess},
onClose,
}: {
input: {id: string; title?: string; onSuccess?: () => void}
input: {id: UnpackedHypermediaId; title?: string; onSuccess?: () => void}
onClose?: () => void
}) {
const deleteEntity = useDeleteEntity({
const deleteEntity = useDeleteEntities({
onSuccess: () => {
onClose?.(), onSuccess?.()
},
})
const {
control,
handleSubmit,
setFocus,
formState: {errors},
} = useForm<DeleteFormFields>({
resolver: zodResolver(deleteFormSchema),
defaultValues: {
description: title
? `Deleted "${title}" because...`
: 'Deleted because...',
},
})
const hid = unpackHmId(id)
const onSubmit: SubmitHandler<DeleteFormFields> = (data) => {
console.log('DeleteEntityDialog.onSubmit', {id, data})
deleteEntity.mutate({
id,
reason: data.description,
})
}
useEffect(() => {
setFocus('description')
}, [setFocus])
if (!hid) throw new Error('Invalid id passed to DeleteEntityDialog')
const list = useListSite(id)
const childDocs =
list.data?.filter((item) => {
if (!item.path?.length) return false
if (!id.path) return false
if (id.path.length === item.path.length) return false
return item.path.join('/').startsWith(id.path.join('/'))
}) || []
console.log(`== ~ DeleteEntityDialog`, id, title, childDocs)
const cap = useMyCapability(id)

return (
<YStack backgroundColor="$background" padding="$4" borderRadius="$3">
<AlertDialog.Title>
Delete this {HYPERMEDIA_ENTITY_TYPES[hid.type]}
</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete this from your computer? It will also be
blocked to prevent you seeing it again.
</AlertDialog.Description>
<AlertDialog.Title>Delete "{title}"</AlertDialog.Title>
<AlertDialog.Description>
You may describe your reason for deleting+blocking this{' '}
{HYPERMEDIA_ENTITY_TYPES[hid.type].toLocaleLowerCase()} below.
Are you sure you want to delete this? (TODO: better message, deletion is
not real. children deleted too)
</AlertDialog.Description>
<Form onSubmit={handleSubmit(onSubmit)} gap="$4">
<FormField name="description" errors={errors}>
<FormTextArea
control={control}
name="description"
placeholder="Reason for deleting..."
/>
</FormField>
<XStack space="$3" justifyContent="flex-end">
<AlertDialog.Cancel asChild>
<Button onPress={onClose} chromeless>
Cancel
</Button>
</AlertDialog.Cancel>
<Form.Trigger asChild>
<Button theme="red">
{`Delete + Block ${HYPERMEDIA_ENTITY_TYPES[hid.type]}`}
</Button>
</Form.Trigger>
<XStack space="$3" justifyContent="flex-end">
<AlertDialog.Cancel asChild>
<Button onPress={onClose} chromeless>
Cancel
</Button>
</AlertDialog.Cancel>
<XStack gap="$4">
{deleteEntity.isLoading ? <Spinner /> : null}
<Button
theme="red"
onPress={() => {
if (!cap || !roleCanWrite(cap?.role))
throw new Error('Not allowed to delete')
deleteEntity.mutate({
ids: [
id,
...childDocs.map((item) =>
hmId('d', id.uid, {path: item.path}),
),
],
signingAccountUid: cap.accountUid,
capabilityId: cap.capabilityId,
})
}}
>
{`Delete ${HYPERMEDIA_ENTITY_TYPES[id.type]}`}
</Button>
</XStack>
</Form>
</XStack>
</YStack>
)
}
Expand Down
40 changes: 26 additions & 14 deletions frontend/apps/desktop/src/components/titlebar-common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,6 @@ export function DocOptionsButton() {
})
},
},
{
key: 'delete',
label: 'Delete Publication',
icon: Trash,
onPress: () => {
deleteEntity.open({
id: route.id.id,
title: getMetadataName(doc.data?.document?.metadata),
onSuccess: () => {
dispatch({type: 'pop'})
},
})
},
},
]
if (siteUrl) {
menuItems.unshift({
Expand All @@ -164,6 +150,32 @@ export function DocOptionsButton() {
},
})
}
const document = doc.data?.document
if (document && canEditDoc && route.id.path?.length) {
menuItems.push({
key: 'delete',
label: 'Delete Document',
icon: Trash,
onPress: () => {
const title = getMetadataName(document.metadata)
deleteEntity.open({
id: route.id,
title,
onSuccess: () => {
dispatch({
type: 'backplace',
route: {
key: 'document',
id: hmId('d', route.id.uid, {
path: route.id.path?.slice(0, -1),
}),
} as any,
})
},
})
},
})
}
if (!route.id.path?.length && canEditDoc) {
if (doc.data?.document?.metadata?.siteUrl)
menuItems.push({
Expand Down
21 changes: 16 additions & 5 deletions frontend/apps/desktop/src/models/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function getRoleCapabilityType(role: Role): HMRole | null {
type HMCapability = {
accountUid: string
role: HMRole
capabilityId?: string
}

const CapabilityInheritance: Readonly<HMRole[]> =
Expand All @@ -88,14 +89,24 @@ export function useMyCapability(
if (myAccounts.data?.indexOf(id.uid) !== -1) {
return {accountUid: id.uid, role: 'owner'}
}
const myCapability = capabilities.data?.find((cap) => {
return !!myAccounts.data?.find(
(myAccountUid) => myAccountUid === cap.delegate,
const myCapability = [...(capabilities.data || [])]
?.sort(
// sort by capability id for deterministic capability selection
(a, b) => a.id.localeCompare(b.id),
)
})
.find((cap) => {
return !!myAccounts.data?.find(
(myAccountUid) => myAccountUid === cap.delegate,
)
})
if (myCapability) {
const role = getRoleCapabilityType(myCapability.role)
if (role) return {accountUid: myCapability.delegate, role: 'writer'}
if (role)
return {
accountUid: myCapability.delegate,
role: 'writer',
capabilityId: myCapability.id,
}
}
return null
}
Expand Down
61 changes: 39 additions & 22 deletions frontend/apps/desktop/src/models/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,41 +24,58 @@ import {useGRPCClient, useQueryInvalidator} from '../app-context'
import {queryKeys} from './query-keys'
import {useDeleteRecent} from './recents'

export function useDeleteEntity(
opts: UseMutationOptions<void, unknown, {id: string; reason: string}>,
type DeleteEntitiesInput = {
ids: UnpackedHypermediaId[]
capabilityId?: string
signingAccountUid: string
}

export function useDeleteEntities(
opts: UseMutationOptions<void, unknown, DeleteEntitiesInput>,
) {
const deleteRecent = useDeleteRecent()
const invalidate = useQueryInvalidator()
const grpcClient = useGRPCClient()
return useMutation({
...opts,
mutationFn: async ({id, reason}: {id: string; reason: string}) => {
await deleteRecent.mutateAsync(id)
await grpcClient.entities.deleteEntity({id, reason})
mutationFn: async ({
ids,
capabilityId,
signingAccountUid,
}: DeleteEntitiesInput) => {
await Promise.all(
ids.map(async (id) => {
await deleteRecent.mutateAsync(id.id)
await grpcClient.documents.createRef({
account: id.uid || '',
path: hmIdPathToEntityQueryPath(id.path),
signingKeyName: signingAccountUid,
capability: capabilityId,
target: {target: {case: 'tombstone', value: {}}},
})
}),
)
},
onSuccess: (
result: void,
variables: {id: string; reason: string},
context,
) => {
const hmId = unpackHmId(variables.id)
if (hmId?.type === 'd') {
invalidate([queryKeys.ENTITY, variables.id])
invalidate([queryKeys.ACCOUNT_DOCUMENTS])
invalidate([queryKeys.LIST_ACCOUNTS])
invalidate([queryKeys.ACCOUNT, hmId.uid])
} else if (hmId?.type === 'comment') {
invalidate([queryKeys.ENTITY, variables.id])
invalidate([queryKeys.COMMENT, variables.id])
invalidate([queryKeys.DOCUMENT_COMMENTS])
}
onSuccess: (result: void, input: DeleteEntitiesInput, context) => {
input.ids.forEach((id) => {
if (id.type === 'd') {
invalidate([queryKeys.ENTITY, id.id])
invalidate([queryKeys.ACCOUNT_DOCUMENTS])
invalidate([queryKeys.LIST_ACCOUNTS])
invalidate([queryKeys.ACCOUNT, id.uid])
} else if (id.type === 'comment') {
invalidate([queryKeys.ENTITY, id])
invalidate([queryKeys.COMMENT, id])
invalidate([queryKeys.DOCUMENT_COMMENTS])
}
})
invalidate([queryKeys.FEED])
invalidate([queryKeys.FEED_LATEST_EVENT])
invalidate([queryKeys.RESOURCE_FEED])
invalidate([queryKeys.RESOURCE_FEED_LATEST_EVENT])
invalidate([queryKeys.ENTITY_CITATIONS])
invalidate([queryKeys.SEARCH])
opts?.onSuccess?.(result, variables, context)
opts?.onSuccess?.(result, input, context)
},
})
}
Expand Down

0 comments on commit 0a4f2b5

Please sign in to comment.