Compare commits

...

2 Commits

Author SHA1 Message Date
Anthony LC
9e4e557173 (frontend) Can mask a document in the list view
We can be member of some documents, but sometimes we
want to mask them from the list view because we
don't want to interact with them anymore.
This commit adds the ability to mask a
document in the list view.
2025-07-29 09:48:04 +02:00
Anthony LC
11dfc9ff03 (backend) add is_masked to document view
We added the `is_masked` annotation to the
document view to indicate whether a document
is masked for the current user.
This will allow the frontend to handle
masked documents appropriately in the UI.
2025-07-29 09:48:04 +02:00
17 changed files with 230 additions and 21 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to
- ✨(frontend) subdocs can manage link reach #1190
- ✨(frontend) add duplicate action to doc tree #1175
- ✨(frontend) add multi columns support for editor #1219
- ✨(frontend) Can mask a document from the list view #1233
### Changed

View File

@@ -66,6 +66,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists."""
is_favorite = serializers.BooleanField(read_only=True)
is_masked = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True)
user_role = serializers.SerializerMethodField(read_only=True)
@@ -85,6 +86,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"depth",
"excerpt",
"is_favorite",
"is_masked",
"link_role",
"link_reach",
"nb_accesses_ancestors",
@@ -107,6 +109,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"depth",
"excerpt",
"is_favorite",
"is_masked",
"link_role",
"link_reach",
"nb_accesses_ancestors",
@@ -176,6 +179,7 @@ class DocumentSerializer(ListDocumentSerializer):
"depth",
"excerpt",
"is_favorite",
"is_masked",
"link_role",
"link_reach",
"nb_accesses_ancestors",

View File

@@ -405,6 +405,7 @@ class DocumentViewSet(
queryset = super().filter_queryset(queryset)
user = self.request.user
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_is_masked(user)
queryset = queryset.annotate_user_roles(user)
return queryset
@@ -453,8 +454,9 @@ class DocumentViewSet(
)
queryset = queryset.filter(path__in=root_paths)
# Annotate favorite status and filter if applicable as late as possible
# Annotate favorite and masked status and filter if applicable as late as possible
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_is_masked(user)
for field in ["is_favorite", "is_masked"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])

View File

@@ -326,6 +326,18 @@ class DocumentQuerySet(MP_NodeQuerySet):
return self.annotate(is_favorite=models.Value(False))
def annotate_is_masked(self, user):
"""
Annotate document queryset with the masked status for the current user.
"""
if user.is_authenticated:
masked_exists_subquery = LinkTrace.objects.filter(
document_id=models.OuterRef("pk"), user=user, is_masked=True
)
return self.annotate(is_masked=models.Exists(masked_exists_subquery))
return self.annotate(is_masked=models.Value(False))
def annotate_user_roles(self, user):
"""
Annotate document queryset with the roles of the current user

View File

@@ -45,6 +45,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -67,6 +68,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -119,6 +121,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -141,6 +144,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -212,6 +216,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -234,6 +239,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -291,6 +297,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -313,6 +320,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -397,6 +405,7 @@ def test_api_documents_children_list_authenticated_related_direct(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -419,6 +428,7 @@ def test_api_documents_children_list_authenticated_related_direct(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -479,6 +489,7 @@ def test_api_documents_children_list_authenticated_related_parent(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -501,6 +512,7 @@ def test_api_documents_children_list_authenticated_related_parent(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -613,6 +625,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -635,6 +648,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,

View File

@@ -42,6 +42,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -66,6 +67,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -88,6 +90,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -139,6 +142,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -161,6 +165,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -183,6 +188,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -255,6 +261,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -277,6 +284,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -299,6 +307,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -356,6 +365,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -378,6 +388,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -400,6 +411,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -478,6 +490,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -500,6 +513,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -522,6 +536,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -580,6 +595,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -602,6 +618,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -624,6 +641,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -728,6 +746,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -750,6 +769,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -772,6 +792,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,

View File

@@ -72,6 +72,7 @@ def test_api_documents_list_format():
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": True,
"is_masked": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
@@ -408,6 +409,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert len(results) == 5
assert all(result["is_favorite"] is False for result in results)
assert all(result["is_masked"] is False for result in results)
# Mark documents as favorite and check results again
for document in special_documents:
@@ -427,3 +429,5 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
# All documents should be unmasked in this test
assert result["is_masked"] is False

View File

@@ -24,6 +24,7 @@ import {
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
useDuplicateDoc,
useMaskDocOption,
} from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share';
import {
@@ -81,6 +82,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const maskDocOption = useMaskDocOption(doc);
useEffect(() => {
if (selectHistoryModal.isOpen) {
@@ -126,6 +128,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
showSeparator: true,
},
{
label: t('Version history'),
@@ -162,17 +165,23 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
canSave: doc.abilities.partial_update,
});
},
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
showSeparator: true,
},
];
const leaveDocOption: DropdownMenuOption = doc.abilities.destroy
? {
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
}
: maskDocOption;
options.push(leaveDocOption);
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
return (

View File

@@ -4,7 +4,8 @@ export * from './useDeleteFavoriteDoc';
export * from './useDoc';
export * from './useDocOptions';
export * from './useDocs';
export * from './useSubDocs';
export * from './useDuplicateDoc';
export * from './useMaskDoc';
export * from './useSubDocs';
export * from './useUpdateDoc';
export * from './useUpdateDocLink';

View File

@@ -16,6 +16,7 @@ export type DocsParams = {
is_creator_me?: boolean;
title?: string;
is_favorite?: boolean;
is_masked?: boolean;
};
export const constructParams = (params: DocsParams): URLSearchParams => {
@@ -36,6 +37,9 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
if (params.is_favorite !== undefined) {
searchParams.set('is_favorite', params.is_favorite.toString());
}
if (params.is_masked !== undefined) {
searchParams.set('is_masked', params.is_masked.toString());
}
return searchParams;
};

View File

@@ -0,0 +1,77 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/docs/doc-management';
export type MaskDocParams = Pick<Doc, 'id'>;
export const maskDoc = async ({ id }: MaskDocParams) => {
const response = await fetchAPI(`documents/${id}/mask/`, {
method: 'POST',
});
if (!response.ok) {
throw new APIError(
'Failed to make the doc as masked',
await errorCauses(response),
);
}
};
interface MaskDocProps {
onSuccess?: () => void;
listInvalideQueries?: string[];
}
export function useMaskDoc({ onSuccess, listInvalideQueries }: MaskDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, MaskDocParams>({
mutationFn: maskDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
onSuccess?.();
},
});
}
export type DeleteMaskDocParams = Pick<Doc, 'id'>;
export const deleteMaskDoc = async ({ id }: DeleteMaskDocParams) => {
const response = await fetchAPI(`documents/${id}/mask/`, {
method: 'DELETE',
});
if (!response.ok) {
throw new APIError(
'Failed to remove the doc as masked',
await errorCauses(response),
);
}
};
interface DeleteMaskDocProps {
onSuccess?: () => void;
listInvalideQueries?: string[];
}
export function useDeleteMaskDoc({
onSuccess,
listInvalideQueries,
}: DeleteMaskDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteMaskDocParams>({
mutationFn: deleteMaskDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
onSuccess?.();
},
});
}

View File

@@ -2,4 +2,5 @@ export * from './useCollaboration';
export * from './useCopyDocLink';
export * from './useDocUtils';
export * from './useIsCollaborativeEditable';
export * from './useMaskDocOption';
export * from './useTrans';

View File

@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { DropdownMenuOption } from '@/components';
import { KEY_DOC, KEY_LIST_DOC, useDeleteMaskDoc, useMaskDoc } from '../api';
import { Doc } from '../types';
export const useMaskDocOption = (doc: Doc) => {
const { t } = useTranslation();
const maskDoc = useMaskDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const deleteMaskDoc = useDeleteMaskDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const leaveDocOption: DropdownMenuOption = doc.is_masked
? {
label: t('Join the doc'),
icon: 'login',
callback: () => {
deleteMaskDoc.mutate({
id: doc.id,
});
},
disabled: !doc.abilities.mask,
testId: `docs-grid-actions-mask-${doc.id}`,
}
: {
label: t('Leave doc'),
icon: 'logout',
callback: () => {
maskDoc.mutate({
id: doc.id,
});
},
disabled: !doc.abilities.mask,
testId: `docs-grid-actions-mask-${doc.id}`,
};
return leaveDocOption;
};

View File

@@ -59,6 +59,7 @@ export interface Doc {
depth: number;
path: string;
is_favorite: boolean;
is_masked: boolean;
link_reach: LinkReach;
link_role: LinkRole;
nb_accesses_direct: number;
@@ -84,6 +85,7 @@ export interface Doc {
favorite: boolean;
invite_owner: boolean;
link_configuration: boolean;
mask: boolean;
media_auth: boolean;
move: boolean;
partial_update: boolean;

View File

@@ -32,10 +32,14 @@ export const DocsGrid = ({
hasNextPage,
} = useInfiniteDocs({
page: 1,
...(target &&
target !== DocDefaultFilter.ALL_DOCS && {
is_creator_me: target === DocDefaultFilter.MY_DOCS,
}),
is_masked:
!target || target === DocDefaultFilter.ALL_DOCS ? false : undefined,
is_creator_me:
target === DocDefaultFilter.MY_DOCS
? true
: target === DocDefaultFilter.SHARED_WITH_ME
? false
: undefined,
});
const docs = data?.pages.flatMap((page) => page.results) ?? [];

View File

@@ -9,6 +9,7 @@ import {
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
useDuplicateDoc,
useMaskDocOption,
} from '@/docs/doc-management';
interface DocsGridActionsProps {
@@ -31,6 +32,7 @@ export const DocsGridActions = ({
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC],
});
const maskDocOption = useMaskDocOption(doc);
const options: DropdownMenuOption[] = [
{
@@ -44,6 +46,7 @@ export const DocsGridActions = ({
}
},
testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
showSeparator: true,
},
{
label: t('Share'),
@@ -65,16 +68,22 @@ export const DocsGridActions = ({
canSave: false,
});
},
},
{
label: t('Remove'),
icon: 'delete',
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`,
showSeparator: true,
},
];
const leaveDocOption: DropdownMenuOption = doc.abilities.destroy
? {
label: t('Delete document'),
icon: 'delete',
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`,
}
: maskDocOption;
options.push(leaveDocOption);
return (
<>
<DropdownMenu options={options}>

View File

@@ -174,6 +174,7 @@ export class ApiPlugin implements WorkboxPlugin {
creator: 'dummy-id',
depth: 1,
is_favorite: false,
is_masked: false,
nb_accesses_direct: 1,
nb_accesses_ancestors: 1,
numchild: 0,
@@ -192,6 +193,7 @@ export class ApiPlugin implements WorkboxPlugin {
favorite: true,
invite_owner: true,
link_configuration: true,
mask: true,
media_auth: true,
move: true,
partial_update: true,