diff --git a/backend/src/app/core/analysis/annotated_segments.py b/backend/src/app/core/analysis/annotated_segments.py index 0c8cb8af4..4f3fec8b2 100644 --- a/backend/src/app/core/analysis/annotated_segments.py +++ b/backend/src/app/core/analysis/annotated_segments.py @@ -21,6 +21,7 @@ from app.core.data.orm.source_document import SourceDocumentORM from app.core.data.orm.span_annotation import SpanAnnotationORM from app.core.data.orm.span_text import SpanTextORM +from app.core.data.orm.user import UserORM from app.core.db.sql_service import SQLService from app.core.filters.columns import ( AbstractColumns, @@ -43,6 +44,7 @@ def aggregate_ids(column: InstrumentedAttribute, label: str): class AnnotatedSegmentsColumns(str, AbstractColumns): SPAN_TEXT = "ASC_SPAN_TEXT" CODE_ID = "ASC_CODE_ID" + USER_ID = "ASC_USER_ID" MEMO_CONTENT = "ASC_MEMO_CONTENT" SOURCE_DOCUMENT_FILENAME = "ASC_SOURCE_SOURCE_DOCUMENT_FILENAME" DOCUMENT_TAG_ID_LIST = "ASC_DOCUMENT_DOCUMENT_TAG_ID_LIST" @@ -59,6 +61,8 @@ def get_filter_column(self, **kwargs): return SpanTextORM.text case AnnotatedSegmentsColumns.MEMO_CONTENT: return MemoORM.content + case AnnotatedSegmentsColumns.USER_ID: + return UserORM.id def get_filter_operator(self) -> FilterOperator: match self: @@ -72,6 +76,8 @@ def get_filter_operator(self) -> FilterOperator: return FilterOperator.STRING case AnnotatedSegmentsColumns.MEMO_CONTENT: return FilterOperator.STRING + case AnnotatedSegmentsColumns.USER_ID: + return FilterOperator.ID def get_filter_value_type(self) -> FilterValueType: match self: @@ -85,6 +91,8 @@ def get_filter_value_type(self) -> FilterValueType: return FilterValueType.INFER_FROM_OPERATOR case AnnotatedSegmentsColumns.MEMO_CONTENT: return FilterValueType.INFER_FROM_OPERATOR + case AnnotatedSegmentsColumns.USER_ID: + return FilterValueType.USER_ID def get_sort_column(self, **kwargs): match self: @@ -98,6 +106,8 @@ def get_sort_column(self, **kwargs): return SpanTextORM.text case AnnotatedSegmentsColumns.MEMO_CONTENT: return MemoORM.content + case AnnotatedSegmentsColumns.USER_ID: + return UserORM.last_name def get_label(self) -> str: match self: @@ -111,6 +121,8 @@ def get_label(self) -> str: return "Annotated text" case AnnotatedSegmentsColumns.MEMO_CONTENT: return "Memo content" + case AnnotatedSegmentsColumns.USER_ID: + return "User" def find_annotated_segments_info( @@ -152,7 +164,6 @@ def find_annotated_segments( .filter( MemoORM.project_id == project_id, # memo is in the correct project MemoORM.user_id == user_id, # i own the memo - AnnotationDocumentORM.user_id == user_id, # i own the annotation ) .subquery() ) @@ -175,6 +186,11 @@ def find_annotated_segments( SourceDocumentORM, SourceDocumentORM.id == AnnotationDocumentORM.source_document_id, ) + # join with User + .join( + UserORM, + UserORM.id == AnnotationDocumentORM.user_id, + ) # join Source Document with Document Tag .join(SourceDocumentORM.document_tags, isouter=True) # join Span Annotation with Code @@ -200,7 +216,6 @@ def find_annotated_segments( MemoORM.content, ) .filter( - AnnotationDocumentORM.user_id == user_id, SourceDocumentORM.project_id == project_id, ) ) diff --git a/backend/src/app/core/data/dto/export_job.py b/backend/src/app/core/data/dto/export_job.py index 3558b1493..523aa40e6 100644 --- a/backend/src/app/core/data/dto/export_job.py +++ b/backend/src/app/core/data/dto/export_job.py @@ -16,10 +16,13 @@ class ExportFormat(str, Enum): class ExportJobType(str, Enum): SINGLE_PROJECT_ALL_DATA = "SINGLE_PROJECT_ALL_DATA" SINGLE_PROJECT_ALL_TAGS = "SINGLE_PROJECT_ALL_TAGS" + SINGLE_PROJECT_ALL_CODES = "SINGLE_PROJECT_ALL_CODES" SINGLE_PROJECT_SELECTED_SDOCS = "SINGLE_PROJECT_SELECTED_SDOCS" + SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS = ( + "SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS" + ) SINGLE_USER_ALL_DATA = "SINGLE_USER_ALL_DATA" - SINGLE_USER_ALL_CODES = "SINGLE_USER_ALL_CODES" SINGLE_USER_ALL_MEMOS = "SINGLE_USER_ALL_MEMOS" SINGLE_USER_LOGBOOK = "SINGLE_USER_LOGBOOK" @@ -42,18 +45,24 @@ class SingleProjectAllTagsExportJobParams(SpecificExportJobParameters): export_job_type: Literal[ExportJobType.SINGLE_PROJECT_ALL_TAGS] +class SingleProjectAllCodesExportJobParams(SpecificExportJobParameters): + export_job_type: Literal[ExportJobType.SINGLE_PROJECT_ALL_CODES] + + class SingleProjectSelectedSdocsParams(SpecificExportJobParameters): export_job_type: Literal[ExportJobType.SINGLE_PROJECT_SELECTED_SDOCS] sdoc_ids: List[int] = Field(description="IDs of the source documents to export") -class SingleUserAllDataExportJobParams(SpecificExportJobParameters): - export_job_type: Literal[ExportJobType.SINGLE_USER_ALL_DATA] - user_id: int = Field(description="The ID of the User to get the data from.") +class SingleProjectSelectedSpanAnnotationsParams(SpecificExportJobParameters): + export_job_type: Literal[ExportJobType.SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS] + span_annotation_ids: List[int] = Field( + description="IDs of the span annotations to export" + ) -class SingleUserAllCodesExportJobParams(SpecificExportJobParameters): - export_job_type: Literal[ExportJobType.SINGLE_USER_ALL_CODES] +class SingleUserAllDataExportJobParams(SpecificExportJobParameters): + export_job_type: Literal[ExportJobType.SINGLE_USER_ALL_DATA] user_id: int = Field(description="The ID of the User to get the data from.") @@ -89,9 +98,10 @@ class ExportJobParameters(BaseModel): specific_export_job_parameters: Union[ SingleProjectAllDataExportJobParams, SingleProjectAllTagsExportJobParams, + SingleProjectAllCodesExportJobParams, SingleProjectSelectedSdocsParams, + SingleProjectSelectedSpanAnnotationsParams, SingleUserAllDataExportJobParams, - SingleUserAllCodesExportJobParams, SingleUserAllMemosExportJobParams, SingleUserLogbookExportJobParams, SingleDocAllUserAnnotationsExportJobParams, diff --git a/backend/src/app/core/data/export/export_service.py b/backend/src/app/core/data/export/export_service.py index eb10ea03f..83521e6a3 100644 --- a/backend/src/app/core/data/export/export_service.py +++ b/backend/src/app/core/data/export/export_service.py @@ -13,6 +13,7 @@ from app.core.data.crud.project import crud_project from app.core.data.crud.source_document import crud_sdoc from app.core.data.crud.source_document_metadata import crud_sdoc_meta +from app.core.data.crud.span_annotation import crud_span_anno from app.core.data.crud.user import crud_user from app.core.data.dto.background_job_base import BackgroundJobStatus from app.core.data.dto.bbox_annotation import ( @@ -100,7 +101,9 @@ def __new__(cls, *args, **kwargs): cls.export_method_for_job_type: Dict[ExportJobType, Callable[..., str]] = { ExportJobType.SINGLE_PROJECT_ALL_DATA: cls._export_all_data_from_proj, ExportJobType.SINGLE_PROJECT_ALL_TAGS: cls._export_all_tags_from_proj, + ExportJobType.SINGLE_PROJECT_ALL_CODES: cls._export_all_codes_from_proj, ExportJobType.SINGLE_PROJECT_SELECTED_SDOCS: cls._export_selected_sdocs_from_proj, + ExportJobType.SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS: cls._export_selected_span_annotations_from_proj, ExportJobType.SINGLE_USER_ALL_DATA: cls._export_user_data_from_proj, ExportJobType.SINGLE_USER_ALL_MEMOS: cls._export_user_memos_from_proj, ExportJobType.SINGLE_USER_LOGBOOK: cls._export_user_logbook_from_proj, @@ -272,6 +275,40 @@ def __generate_export_df_for_adoc( df = pd.DataFrame(data=data) return df + def __generate_export_df_for_span_annotations( + self, + db: Session, + span_annotations: List[SpanAnnotationORM], + ) -> pd.DataFrame: + logger.info(f"Exporting {len(span_annotations)} Annotations ...") + + # fill the DataFrame + data = { + "sdoc_name": [], + "user_first_name": [], + "user_last_name": [], + "code_name": [], + "created": [], + "text": [], + "text_begin_char": [], + "text_end_char": [], + } + + for span in span_annotations: + sdoc = span.annotation_document.source_document + user = span.annotation_document.user + data["sdoc_name"].append(sdoc.filename) + data["user_first_name"].append(user.first_name) + data["user_last_name"].append(user.last_name) + data["code_name"].append(span.code.name) + data["created"].append(span.created) + data["text"].append(span.text) + data["text_begin_char"].append(span.begin) + data["text_end_char"].append(span.end) + + df = pd.DataFrame(data=data) + return df + def __generate_export_df_for_memo( self, db: Session, @@ -894,6 +931,30 @@ def _export_all_tags_from_proj( logger.error(msg) raise NoDataToExportError(msg) + def _export_all_codes_from_proj( + self, + db: Session, + project_id: int, + export_format: ExportFormat = ExportFormat.CSV, + ) -> str: + ex_codes = self.__generate_export_dfs_for_all_codes_in_project( + db=db, project_id=project_id + ) + + # one file for all tags + if len(ex_codes) > 0: + export_data = pd.concat(ex_codes) + export_file = self.__write_export_data_to_temp_file( + data=export_data, + export_format=export_format, + fn=f"project_{project_id}_codes", + ) + export_url = self.repo.get_temp_file_url(export_file.name, relative=True) + return export_url + msg = f"No Codes to export in Project {project_id}" + logger.error(msg) + raise NoDataToExportError(msg) + def _export_selected_sdocs_from_proj( self, db: Session, project_id: int, sdoc_ids: List[int] ) -> str: @@ -904,6 +965,22 @@ def _export_selected_sdocs_from_proj( return self.repo.get_temp_file_url(zip.name, relative=True) + def _export_selected_span_annotations_from_proj( + self, db: Session, project_id: int, span_annotation_ids: List[int] + ) -> str: + # get the annotations + span_annotations = crud_span_anno.read_by_ids(db=db, ids=span_annotation_ids) + + export_data = self.__generate_export_df_for_span_annotations( + db=db, span_annotations=span_annotations + ) + export_file = self.__write_export_data_to_temp_file( + data=export_data, + export_format=ExportFormat.CSV, + fn=f"project_{project_id}_selected_annotations_export", + ) + return self.repo.get_temp_file_url(export_file.name, relative=True) + def _assert_all_requested_data_exists( self, export_params: ExportJobParameters ) -> None: diff --git a/frontend/src/api/openapi/models/AnnotatedSegmentsColumns.ts b/frontend/src/api/openapi/models/AnnotatedSegmentsColumns.ts index 1303510d9..5d729edc3 100644 --- a/frontend/src/api/openapi/models/AnnotatedSegmentsColumns.ts +++ b/frontend/src/api/openapi/models/AnnotatedSegmentsColumns.ts @@ -5,6 +5,7 @@ export enum AnnotatedSegmentsColumns { ASC_SPAN_TEXT = "ASC_SPAN_TEXT", ASC_CODE_ID = "ASC_CODE_ID", + ASC_USER_ID = "ASC_USER_ID", ASC_MEMO_CONTENT = "ASC_MEMO_CONTENT", ASC_SOURCE_SOURCE_DOCUMENT_FILENAME = "ASC_SOURCE_SOURCE_DOCUMENT_FILENAME", ASC_DOCUMENT_DOCUMENT_TAG_ID_LIST = "ASC_DOCUMENT_DOCUMENT_TAG_ID_LIST", diff --git a/frontend/src/api/openapi/models/ExportJobParameters.ts b/frontend/src/api/openapi/models/ExportJobParameters.ts index 902cc5e37..459e16581 100644 --- a/frontend/src/api/openapi/models/ExportJobParameters.ts +++ b/frontend/src/api/openapi/models/ExportJobParameters.ts @@ -6,10 +6,11 @@ import type { ExportFormat } from "./ExportFormat"; import type { ExportJobType } from "./ExportJobType"; import type { SingleDocAllUserAnnotationsExportJobParams } from "./SingleDocAllUserAnnotationsExportJobParams"; import type { SingleDocSingleUserAnnotationsExportJobParams } from "./SingleDocSingleUserAnnotationsExportJobParams"; +import type { SingleProjectAllCodesExportJobParams } from "./SingleProjectAllCodesExportJobParams"; import type { SingleProjectAllDataExportJobParams } from "./SingleProjectAllDataExportJobParams"; import type { SingleProjectAllTagsExportJobParams } from "./SingleProjectAllTagsExportJobParams"; import type { SingleProjectSelectedSdocsParams } from "./SingleProjectSelectedSdocsParams"; -import type { SingleUserAllCodesExportJobParams } from "./SingleUserAllCodesExportJobParams"; +import type { SingleProjectSelectedSpanAnnotationsParams } from "./SingleProjectSelectedSpanAnnotationsParams"; import type { SingleUserAllDataExportJobParams } from "./SingleUserAllDataExportJobParams"; import type { SingleUserAllMemosExportJobParams } from "./SingleUserAllMemosExportJobParams"; import type { SingleUserLogbookExportJobParams } from "./SingleUserLogbookExportJobParams"; @@ -28,9 +29,10 @@ export type ExportJobParameters = { specific_export_job_parameters: | SingleProjectAllDataExportJobParams | SingleProjectAllTagsExportJobParams + | SingleProjectAllCodesExportJobParams | SingleProjectSelectedSdocsParams + | SingleProjectSelectedSpanAnnotationsParams | SingleUserAllDataExportJobParams - | SingleUserAllCodesExportJobParams | SingleUserAllMemosExportJobParams | SingleUserLogbookExportJobParams | SingleDocAllUserAnnotationsExportJobParams diff --git a/frontend/src/api/openapi/models/ExportJobType.ts b/frontend/src/api/openapi/models/ExportJobType.ts index 72ddd435a..6cca1de7d 100644 --- a/frontend/src/api/openapi/models/ExportJobType.ts +++ b/frontend/src/api/openapi/models/ExportJobType.ts @@ -5,9 +5,10 @@ export enum ExportJobType { SINGLE_PROJECT_ALL_DATA = "SINGLE_PROJECT_ALL_DATA", SINGLE_PROJECT_ALL_TAGS = "SINGLE_PROJECT_ALL_TAGS", + SINGLE_PROJECT_ALL_CODES = "SINGLE_PROJECT_ALL_CODES", SINGLE_PROJECT_SELECTED_SDOCS = "SINGLE_PROJECT_SELECTED_SDOCS", + SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS = "SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS", SINGLE_USER_ALL_DATA = "SINGLE_USER_ALL_DATA", - SINGLE_USER_ALL_CODES = "SINGLE_USER_ALL_CODES", SINGLE_USER_ALL_MEMOS = "SINGLE_USER_ALL_MEMOS", SINGLE_USER_LOGBOOK = "SINGLE_USER_LOGBOOK", SINGLE_DOC_ALL_USER_ANNOTATIONS = "SINGLE_DOC_ALL_USER_ANNOTATIONS", diff --git a/frontend/src/api/openapi/models/SingleUserAllCodesExportJobParams.ts b/frontend/src/api/openapi/models/SingleProjectAllCodesExportJobParams.ts similarity index 64% rename from frontend/src/api/openapi/models/SingleUserAllCodesExportJobParams.ts rename to frontend/src/api/openapi/models/SingleProjectAllCodesExportJobParams.ts index afe3cbcb1..5d40932d2 100644 --- a/frontend/src/api/openapi/models/SingleUserAllCodesExportJobParams.ts +++ b/frontend/src/api/openapi/models/SingleProjectAllCodesExportJobParams.ts @@ -2,14 +2,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type SingleUserAllCodesExportJobParams = { +export type SingleProjectAllCodesExportJobParams = { /** * The ID of the Project to export from */ project_id: number; export_job_type: any; - /** - * The ID of the User to get the data from. - */ - user_id: number; }; diff --git a/frontend/src/api/openapi/models/SingleProjectSelectedSpanAnnotationsParams.ts b/frontend/src/api/openapi/models/SingleProjectSelectedSpanAnnotationsParams.ts new file mode 100644 index 000000000..54c86cdcf --- /dev/null +++ b/frontend/src/api/openapi/models/SingleProjectSelectedSpanAnnotationsParams.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SingleProjectSelectedSpanAnnotationsParams = { + /** + * The ID of the Project to export from + */ + project_id: number; + export_job_type: any; + /** + * IDs of the span annotations to export + */ + span_annotation_ids: Array; +}; diff --git a/frontend/src/components/Exporter/ExporterDialog.tsx b/frontend/src/components/Exporter/ExporterDialog.tsx index 969a661d1..a087f511d 100644 --- a/frontend/src/components/Exporter/ExporterDialog.tsx +++ b/frontend/src/components/Exporter/ExporterDialog.tsx @@ -29,9 +29,9 @@ import { ExportJobParameters } from "../../api/openapi/models/ExportJobParameter import { ExportJobType } from "../../api/openapi/models/ExportJobType.ts"; import { SingleDocAllUserAnnotationsExportJobParams } from "../../api/openapi/models/SingleDocAllUserAnnotationsExportJobParams.ts"; import { SingleDocSingleUserAnnotationsExportJobParams } from "../../api/openapi/models/SingleDocSingleUserAnnotationsExportJobParams.ts"; +import { SingleProjectAllCodesExportJobParams } from "../../api/openapi/models/SingleProjectAllCodesExportJobParams.ts"; import { SingleProjectAllDataExportJobParams } from "../../api/openapi/models/SingleProjectAllDataExportJobParams.ts"; import { SingleProjectAllTagsExportJobParams } from "../../api/openapi/models/SingleProjectAllTagsExportJobParams.ts"; -import { SingleUserAllCodesExportJobParams } from "../../api/openapi/models/SingleUserAllCodesExportJobParams.ts"; import { SingleUserAllMemosExportJobParams } from "../../api/openapi/models/SingleUserAllMemosExportJobParams.ts"; import { SingleUserLogbookExportJobParams } from "../../api/openapi/models/SingleUserLogbookExportJobParams.ts"; import { useAuth } from "../../auth/useAuth.ts"; @@ -44,7 +44,7 @@ const enabledComponentsPerType = new Map( Object.entries({ Project: [], Tagset: [], - Codeset: ["users"], + Codeset: [], Memos: ["users"], Logbook: ["users"], Annotations: ["singleUser"], @@ -93,12 +93,11 @@ const exporterInfoToExporterJobParameters = (exporterData: ExporterInfo, project }; case "Codeset": return { - export_job_type: ExportJobType.SINGLE_USER_ALL_CODES, + export_job_type: ExportJobType.SINGLE_PROJECT_ALL_CODES, specific_export_job_parameters: { project_id: projectId, - export_job_type: ExportJobType.SINGLE_USER_ALL_CODES, - user_id: exporterData.users[0], - } as SingleUserAllCodesExportJobParams, + export_job_type: ExportJobType.SINGLE_PROJECT_ALL_CODES, + } as SingleProjectAllCodesExportJobParams, export_format: ExportFormat.CSV, }; case "Memos": diff --git a/frontend/src/components/SpanAnnotation/SpanAnnotationTable/SATToolbar.tsx b/frontend/src/components/SpanAnnotation/SpanAnnotationTable/SATToolbar.tsx index 5fa12c5c6..7397b86fa 100644 --- a/frontend/src/components/SpanAnnotation/SpanAnnotationTable/SATToolbar.tsx +++ b/frontend/src/components/SpanAnnotation/SpanAnnotationTable/SATToolbar.tsx @@ -12,7 +12,6 @@ export interface SATToolbarProps { filterName: string; table: MRT_TableInstance; anchor: React.RefObject; - selectedUserId: number; selectedAnnotations: AnnotationTableRow[]; } diff --git a/frontend/src/components/SpanAnnotation/SpanAnnotationTable/SpanAnnotationTable.tsx b/frontend/src/components/SpanAnnotation/SpanAnnotationTable/SpanAnnotationTable.tsx index a1bf4655d..ad695e994 100644 --- a/frontend/src/components/SpanAnnotation/SpanAnnotationTable/SpanAnnotationTable.tsx +++ b/frontend/src/components/SpanAnnotation/SpanAnnotationTable/SpanAnnotationTable.tsx @@ -10,7 +10,7 @@ import { MaterialReactTable, useMaterialReactTable, } from "material-react-table"; -import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, type UIEvent } from "react"; import { AnnotatedSegmentResult } from "../../../api/openapi/models/AnnotatedSegmentResult.ts"; import { AnnotatedSegmentsColumns } from "../../../api/openapi/models/AnnotatedSegmentsColumns.ts"; import { AnnotationTableRow } from "../../../api/openapi/models/AnnotationTableRow.ts"; @@ -24,7 +24,7 @@ import { MyFilter, createEmptyFilter } from "../../FilterDialog/filterUtils.ts"; import MemoRenderer2 from "../../Memo/MemoRenderer2.tsx"; import SdocMetadataRenderer from "../../Metadata/SdocMetadataRenderer.tsx"; import SdocTagsRenderer from "../../SourceDocument/SdocTagRenderer.tsx"; -import UserSelectorSingle from "../../User/UserSelectorSingle.tsx"; +import UserRenderer from "../../User/UserRenderer.tsx"; import SATToolbar, { SATToolbarProps } from "./SATToolbar.tsx"; import { useInitSATFilterSlice } from "./useInitSATFilterSlice.ts"; @@ -69,9 +69,7 @@ function SpanAnnotationTable({ }: SpanAnnotationTableProps) { // global client state (react router) const { user } = useAuth(); - - // user id selector - const [selectedUserId, setSelectedUserId] = useState(user?.id || 1); + const userId = user?.id; // filtering const filter = useAppSelector((state) => state.satFilter.filter[filterName]) || createEmptyFilter(filterName); @@ -110,6 +108,12 @@ function SpanAnnotationTable({ accessorFn: (row) => row.code, Cell: ({ row }) => , } as MRT_ColumnDef; + case AnnotatedSegmentsColumns.ASC_USER_ID: + return { + ...colDef, + accessorFn: (row) => row.user_id, + Cell: ({ row }) => , + } as MRT_ColumnDef; case AnnotatedSegmentsColumns.ASC_MEMO_CONTENT: return { ...colDef, @@ -158,14 +162,14 @@ function SpanAnnotationTable({ queryKey: [ "annotation-table-data", projectId, - selectedUserId, + userId, filter, //refetch when columnFilters changes sortingModel, //refetch when sorting changes ], queryFn: ({ pageParam }) => AnalysisService.annotatedSegments({ projectId: projectId!, - userId: selectedUserId, + userId: userId!, requestBody: { filter: filter as MyFilter, sorts: sortingModel.map((sort) => ({ @@ -177,6 +181,7 @@ function SpanAnnotationTable({ pageSize: fetchSize, }), initialPageParam: 0, + enabled: !!projectId && !!userId, getNextPageParam: (_lastGroup, groups) => { return groups.length; }, @@ -208,7 +213,7 @@ function SpanAnnotationTable({ } catch (error) { console.error(error); } - }, [projectId, selectedUserId, sortingModel]); + }, [projectId, sortingModel]); // a check on mount to see if the table is already scrolled to the bottom and immediately needs to fetch more data useEffect(() => { fetchMoreOnBottomReached(tableContainerRef.current); @@ -269,7 +274,6 @@ function SpanAnnotationTable({ table: props.table, filterName, anchor: tableContainerRef, - selectedUserId: selectedUserId, selectedAnnotations: flatData.filter((row) => rowSelectionModel[row.id]), }) : undefined, @@ -278,7 +282,6 @@ function SpanAnnotationTable({ table: props.table, filterName, anchor: tableContainerRef, - selectedUserId: selectedUserId, selectedAnnotations: flatData.filter((row) => rowSelectionModel[row.id]), }), renderBottomToolbarCustomActions: (props) => ( @@ -291,7 +294,6 @@ function SpanAnnotationTable({ table: props.table, filterName, anchor: tableContainerRef, - selectedUserId: selectedUserId, selectedAnnotations: flatData.filter((row) => rowSelectionModel[row.id]), })} @@ -300,17 +302,7 @@ function SpanAnnotationTable({ return ( - - } - /> + diff --git a/frontend/src/index.css b/frontend/src/index.css index 73173f750..dddf4b87d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,6 +10,34 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } +.toggle-on-hover .toggle-content { + display: none; +} + +.toggle-on-hover:hover .toggle-content { + display: flex; +} + +.toggle-on-hover-left:hover:after { + position: absolute; + content: ""; + top: 48px; + bottom: 0; + left: 8px; + border: 1px solid rgb(25, 118, 210); + z-index: 0; +} + +.toggle-on-hover-right:hover:after { + position: absolute; + content: ""; + top: 48px; + bottom: 0; + left: 10px; + border: 1px solid rgb(25, 118, 210); + z-index: 0; +} + .hide-button { position: absolute; top: 0; diff --git a/frontend/src/layouts/LayoutManipulationButtons.tsx b/frontend/src/layouts/LayoutManipulationButtons.tsx new file mode 100644 index 000000000..0c4716e89 --- /dev/null +++ b/frontend/src/layouts/LayoutManipulationButtons.tsx @@ -0,0 +1,45 @@ +import AddIcon from "@mui/icons-material/Add"; +import RemoveIcon from "@mui/icons-material/Remove"; +import { Box, Button, Stack } from "@mui/material"; +interface LayoutManipulationButtonsProps { + onIncreaseClick: () => void; + onDecreaseClick: () => void; + isLeft: boolean; +} + +function LayoutManipulationButtons({ onIncreaseClick, onDecreaseClick, isLeft }: LayoutManipulationButtonsProps) { + return ( + + + + + + + ); +} + +export default LayoutManipulationButtons; diff --git a/frontend/src/layouts/TwoSidebarsLayout.tsx b/frontend/src/layouts/TwoSidebarsLayout.tsx index 2f2107712..3f64fcdd8 100644 --- a/frontend/src/layouts/TwoSidebarsLayout.tsx +++ b/frontend/src/layouts/TwoSidebarsLayout.tsx @@ -1,6 +1,8 @@ import { Grid } from "@mui/material"; import { ReactNode } from "react"; - +import { useAppDispatch, useAppSelector } from "../plugins/ReduxHooks.ts"; +import LayoutManipulationButtons from "./LayoutManipulationButtons.tsx"; +import { LayoutActions } from "./layoutSlice.ts"; function TwoSidebarsLayout({ leftSidebar, content, @@ -10,42 +12,66 @@ function TwoSidebarsLayout({ content: ReactNode; rightSidebar: ReactNode; }) { + const leftSidebarSize = useAppSelector((state) => state.layout.leftSidebarSize); + const rightSidebarSize = useAppSelector((state) => state.layout.rightSidebarSize); + const contentSize = useAppSelector((state) => state.layout.contentSize); + const dispatch = useAppDispatch(); + return ( + {leftSidebarSize > 0 && ( + theme.zIndex.appBar, + bgcolor: (theme) => theme.palette.background.paper, + borderRight: "1px solid #e8eaed", + boxShadow: 4, + }} + > + {leftSidebar} + + )} theme.zIndex.appBar, - bgcolor: (theme) => theme.palette.background.paper, - borderRight: "1px solid #e8eaed", - boxShadow: 4, - }} - > - {leftSidebar} - - theme.palette.grey[200], overflow: "auto" }} - > - {content} - - theme.zIndex.appBar, - bgcolor: (theme) => theme.palette.background.paper, - borderLeft: "1px solid #e8eaed", - boxShadow: 4, + bgcolor: (theme) => theme.palette.grey[200], + overflowY: "auto", + overflowX: "hidden", + position: "relative", }} > - {rightSidebar} + dispatch(LayoutActions.onDecreaseLeft())} + onIncreaseClick={() => dispatch(LayoutActions.onIncreaseLeft())} + isLeft={true} + /> + dispatch(LayoutActions.onDecreaseRight())} + onIncreaseClick={() => dispatch(LayoutActions.onIncreaseRight())} + isLeft={false} + /> + {content} + {rightSidebarSize > 0 && ( + theme.zIndex.appBar, + bgcolor: (theme) => theme.palette.background.paper, + borderLeft: "1px solid #e8eaed", + boxShadow: 4, + }} + > + {rightSidebar} + + )} ); } diff --git a/frontend/src/layouts/layoutSlice.ts b/frontend/src/layouts/layoutSlice.ts new file mode 100644 index 000000000..18b8c42a9 --- /dev/null +++ b/frontend/src/layouts/layoutSlice.ts @@ -0,0 +1,52 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { persistReducer } from "redux-persist"; +import storage from "redux-persist/lib/storage"; + +export interface LayoutState { + leftSidebarSize: number; + rightSidebarSize: number; + contentSize: number; +} + +const initialState: LayoutState = { + leftSidebarSize: 3, + rightSidebarSize: 3, + contentSize: 6, +}; + +export const layoutSlice = createSlice({ + name: "layout", + initialState, + reducers: { + onIncreaseLeft: (state) => { + if (state.contentSize === 3) return; + state.leftSidebarSize += 1; + state.contentSize -= 1; + }, + onDecreaseLeft: (state) => { + if (state.leftSidebarSize === 0) return; + state.leftSidebarSize -= 1; + state.contentSize += 1; + }, + onIncreaseRight: (state) => { + if (state.contentSize === 3) return; + state.rightSidebarSize += 1; + state.contentSize -= 1; + }, + onDecreaseRight: (state) => { + if (state.rightSidebarSize === 0) return; + state.rightSidebarSize -= 1; + state.contentSize += 1; + }, + }, +}); + +export const LayoutActions = layoutSlice.actions; + +export default persistReducer( + { + key: "layout", + storage, + }, + layoutSlice.reducer, +); diff --git a/frontend/src/openapi.json b/frontend/src/openapi.json index 76d1a31a2..dc77b1296 100644 --- a/frontend/src/openapi.json +++ b/frontend/src/openapi.json @@ -5787,6 +5787,7 @@ "enum": [ "ASC_SPAN_TEXT", "ASC_CODE_ID", + "ASC_USER_ID", "ASC_MEMO_CONTENT", "ASC_SOURCE_SOURCE_DOCUMENT_FILENAME", "ASC_DOCUMENT_DOCUMENT_TAG_ID_LIST" @@ -6941,9 +6942,10 @@ "oneOf": [ { "$ref": "#/components/schemas/SingleProjectAllDataExportJobParams" }, { "$ref": "#/components/schemas/SingleProjectAllTagsExportJobParams" }, + { "$ref": "#/components/schemas/SingleProjectAllCodesExportJobParams" }, { "$ref": "#/components/schemas/SingleProjectSelectedSdocsParams" }, + { "$ref": "#/components/schemas/SingleProjectSelectedSpanAnnotationsParams" }, { "$ref": "#/components/schemas/SingleUserAllDataExportJobParams" }, - { "$ref": "#/components/schemas/SingleUserAllCodesExportJobParams" }, { "$ref": "#/components/schemas/SingleUserAllMemosExportJobParams" }, { "$ref": "#/components/schemas/SingleUserLogbookExportJobParams" }, { "$ref": "#/components/schemas/SingleDocAllUserAnnotationsExportJobParams" }, @@ -6956,10 +6958,11 @@ "mapping": { "SINGLE_DOC_ALL_USER_ANNOTATIONS": "#/components/schemas/SingleDocAllUserAnnotationsExportJobParams", "SINGLE_DOC_SINGLE_USER_ANNOTATIONS": "#/components/schemas/SingleDocSingleUserAnnotationsExportJobParams", + "SINGLE_PROJECT_ALL_CODES": "#/components/schemas/SingleProjectAllCodesExportJobParams", "SINGLE_PROJECT_ALL_DATA": "#/components/schemas/SingleProjectAllDataExportJobParams", "SINGLE_PROJECT_ALL_TAGS": "#/components/schemas/SingleProjectAllTagsExportJobParams", "SINGLE_PROJECT_SELECTED_SDOCS": "#/components/schemas/SingleProjectSelectedSdocsParams", - "SINGLE_USER_ALL_CODES": "#/components/schemas/SingleUserAllCodesExportJobParams", + "SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS": "#/components/schemas/SingleProjectSelectedSpanAnnotationsParams", "SINGLE_USER_ALL_DATA": "#/components/schemas/SingleUserAllDataExportJobParams", "SINGLE_USER_ALL_MEMOS": "#/components/schemas/SingleUserAllMemosExportJobParams", "SINGLE_USER_LOGBOOK": "#/components/schemas/SingleUserLogbookExportJobParams" @@ -7004,9 +7007,10 @@ "enum": [ "SINGLE_PROJECT_ALL_DATA", "SINGLE_PROJECT_ALL_TAGS", + "SINGLE_PROJECT_ALL_CODES", "SINGLE_PROJECT_SELECTED_SDOCS", + "SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS", "SINGLE_USER_ALL_DATA", - "SINGLE_USER_ALL_CODES", "SINGLE_USER_ALL_MEMOS", "SINGLE_USER_LOGBOOK", "SINGLE_DOC_ALL_USER_ANNOTATIONS", @@ -8120,6 +8124,19 @@ "required": ["project_id", "export_job_type", "sdoc_id", "user_id"], "title": "SingleDocSingleUserAnnotationsExportJobParams" }, + "SingleProjectAllCodesExportJobParams": { + "properties": { + "project_id": { + "type": "integer", + "title": "Project Id", + "description": "The ID of the Project to export from" + }, + "export_job_type": { "const": "SINGLE_PROJECT_ALL_CODES", "title": "Export Job Type" } + }, + "type": "object", + "required": ["project_id", "export_job_type"], + "title": "SingleProjectAllCodesExportJobParams" + }, "SingleProjectAllDataExportJobParams": { "properties": { "project_id": { @@ -8165,23 +8182,24 @@ "required": ["project_id", "export_job_type", "sdoc_ids"], "title": "SingleProjectSelectedSdocsParams" }, - "SingleUserAllCodesExportJobParams": { + "SingleProjectSelectedSpanAnnotationsParams": { "properties": { "project_id": { "type": "integer", "title": "Project Id", "description": "The ID of the Project to export from" }, - "export_job_type": { "const": "SINGLE_USER_ALL_CODES", "title": "Export Job Type" }, - "user_id": { - "type": "integer", - "title": "User Id", - "description": "The ID of the User to get the data from." + "export_job_type": { "const": "SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS", "title": "Export Job Type" }, + "span_annotation_ids": { + "items": { "type": "integer" }, + "type": "array", + "title": "Span Annotation Ids", + "description": "IDs of the span annotations to export" } }, "type": "object", - "required": ["project_id", "export_job_type", "user_id"], - "title": "SingleUserAllCodesExportJobParams" + "required": ["project_id", "export_job_type", "span_annotation_ids"], + "title": "SingleProjectSelectedSpanAnnotationsParams" }, "SingleUserAllDataExportJobParams": { "properties": { diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index 3cfbd6db8..ac31014cf 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -5,6 +5,7 @@ import bboxFilterReducer from "../components/BBoxAnnotation/BBoxAnnotationTable/ import documentTableFilterReducer from "../components/SourceDocument/SdocTable/documentTableFilterSlice.ts"; import satFilterReducer from "../components/SpanAnnotation/SpanAnnotationTable/satFilterSlice.ts"; import dialogReducer from "../components/dialogSlice.ts"; +import layoutReducer from "../layouts/layoutSlice.ts"; import annotatedSegmentsReducer from "../views/analysis/AnnotatedSegments/annotatedSegmentsSlice.ts"; import cotaReducer from "../views/analysis/ConceptsOverTime/cotaSlice.ts"; import timelineAnalysisReducer from "../views/analysis/TimelineAnalysis/timelineAnalysisSlice.ts"; @@ -44,6 +45,7 @@ export const store = configureStore({ cota: cotaReducer, dialog: dialogReducer, documentSampler: documentSamplerReducer, + layout: layoutReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/frontend/src/views/analysis/AnnotatedSegments/AnnotatedSegmentsTableToolbar.tsx b/frontend/src/views/analysis/AnnotatedSegments/AnnotatedSegmentsTableToolbar.tsx index 0a4b7a2ee..c3793045b 100644 --- a/frontend/src/views/analysis/AnnotatedSegments/AnnotatedSegmentsTableToolbar.tsx +++ b/frontend/src/views/analysis/AnnotatedSegments/AnnotatedSegmentsTableToolbar.tsx @@ -1,20 +1,11 @@ -import SaveAltIcon from "@mui/icons-material/SaveAlt"; -import { IconButton, Tooltip } from "@mui/material"; import SATToolbar, { SATToolbarProps } from "../../../components/SpanAnnotation/SpanAnnotationTable/SATToolbar.tsx"; +import ExportAnnotationsButton from "./ExportAnnotationsButton.tsx"; function AnnotatedSegmentsTableToolbar(props: SATToolbarProps) { return ( - - - - - - - } + rightChildren={ a.id)} />} /> ); } diff --git a/frontend/src/views/analysis/AnnotatedSegments/ExportAnnotationsButton.tsx b/frontend/src/views/analysis/AnnotatedSegments/ExportAnnotationsButton.tsx new file mode 100644 index 000000000..384db5370 --- /dev/null +++ b/frontend/src/views/analysis/AnnotatedSegments/ExportAnnotationsButton.tsx @@ -0,0 +1,66 @@ +import SaveAltIcon from "@mui/icons-material/SaveAlt"; +import { CircularProgress, IconButton, Tooltip } from "@mui/material"; +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import ExporterHooks from "../../../api/ExporterHooks.ts"; +import { BackgroundJobStatus } from "../../../api/openapi/models/BackgroundJobStatus.ts"; +import { ExportJobType } from "../../../api/openapi/models/ExportJobType.ts"; +import { useOpenSnackbar } from "../../../components/SnackbarDialog/useOpenSnackbar.ts"; + +interface ExportAnnotationsButtonProps { + spanAnnotationIds: number[]; +} + +export default function ExportAnnotationsButton({ spanAnnotationIds }: ExportAnnotationsButtonProps) { + // global client state (react-router) + const projectId = parseInt((useParams() as { projectId: string }).projectId); + + const startExport = ExporterHooks.useStartExportJob(); + const exportJob = ExporterHooks.useGetExportJob(startExport.data?.id); + + // snackbar + const openSnackbar = useOpenSnackbar(); + + const onClick = () => { + startExport.mutate({ + requestBody: { + export_job_type: ExportJobType.SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS, + specific_export_job_parameters: { + project_id: projectId, + export_job_type: ExportJobType.SINGLE_PROJECT_SELECTED_SPAN_ANNOTATIONS, + span_annotation_ids: spanAnnotationIds, + }, + }, + }); + }; + + useEffect(() => { + if (!exportJob.data) return; + if (exportJob.data.status) { + if (exportJob.data.status === BackgroundJobStatus.FINISHED) { + window.open(import.meta.env.VITE_APP_CONTENT + "/" + exportJob.data.results_url, "_blank"); + // Make sure the download doesn't start again on a re-render + startExport.reset(); + } else if (exportJob.data.status === BackgroundJobStatus.ERRORNEOUS) { + openSnackbar({ + text: `Export job ${exportJob.data.id} failed`, + severity: "error", + }); + } + } + }, [exportJob.data, startExport, openSnackbar]); + + if (startExport.isPending || exportJob.data?.status === BackgroundJobStatus.WAITING) { + return ; + } else { + return ( + + + + + + + + ); + } +} diff --git a/frontend/src/views/annotation/DocumentRenderer/Token.tsx b/frontend/src/views/annotation/DocumentRenderer/Token.tsx index 23b837d74..b50b8e091 100644 --- a/frontend/src/views/annotation/DocumentRenderer/Token.tsx +++ b/frontend/src/views/annotation/DocumentRenderer/Token.tsx @@ -1,3 +1,4 @@ +import { Tooltip, Typography } from "@mui/material"; import { range } from "lodash"; import { useMemo } from "react"; import { SpanAnnotationReadResolved } from "../../../api/openapi/models/SpanAnnotationReadResolved.ts"; @@ -51,14 +52,31 @@ function Token({ token, spanAnnotations }: TokenProps) { return ( <> - `span-${s.id}`).join(" ")}`} data-tokenid={token.index}> - {spanGroups} - - {token.text} + 0 && ( + <> + {spans.map((span) => ( + + {span.code.name}: {span.code.description} + + ))} + + ) + } + followCursor + placement="top" + enterDelay={500} + > + `span-${s.id}`).join(" ")}`} data-tokenid={token.index}> + {spanGroups} + + {token.text} + + {token.whitespace && " "} + {marks} - {token.whitespace && " "} - {marks} - + {token.newLine > 0 && range(token.newLine).map((i) =>

)} );