diff --git a/CHANGELOG.md b/CHANGELOG.md index b3d9457eb..32db1dba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to - ✨(frontend) add customization for translations #857 - 📝(project) add troubleshoot doc #1066 - 📝(project) add system-requirement doc #1066 +- ✨(frontend) Add a custom database block to the editor #1053 ### Changed @@ -28,7 +29,6 @@ and this project adheres to -🐛(frontend) fix meta title #1017 -🔧(git) set LF line endings for all text files #1032 - ## [3.3.0] - 2025-05-06 ### Added @@ -54,13 +54,13 @@ and this project adheres to - ⬆️(docker) upgrade node images to alpine 3.21 #973 ### Fixed + - 🐛(y-provider) increase JSON size limits for transcription conversion #989 ### Removed - 🔥(back) remove footer endpoint #948 - ## [3.2.1] - 2025-05-06 ## Fixed @@ -68,7 +68,6 @@ and this project adheres to - 🐛(frontend) fix list copy paste #943 - 📝(doc) update contributing policy (commit signatures are now mandatory) #895 - ## [3.2.0] - 2025-05-05 ## Added @@ -79,7 +78,7 @@ and this project adheres to - ✨(settings) Allow configuring PKCE for the SSO #886 - 🌐(i18n) activate chinese and spanish languages #884 - 🔧(backend) allow overwriting the data directory #893 -- ➕(backend) add `django-lasuite` dependency #839 +- ➕(backend) add `django-lasuite` dependency #839 - ✨(frontend) advanced table features #908 ## Changed @@ -130,7 +129,6 @@ and this project adheres to - 🐛(backend) compute ancestor_links in get_abilities if needed #725 - 🔒️(back) restrict access to document accesses #801 - ## [2.6.0] - 2025-03-21 ## Added @@ -149,7 +147,6 @@ and this project adheres to - 🔒️(back) throttle user list endpoint #636 - 🔒️(back) remove pagination and limit to 5 for user list endpoint #636 - ## [2.5.0] - 2025-03-18 ## Added @@ -172,15 +169,14 @@ and this project adheres to ## Fixed - 🐛(frontend) SVG export #706 -- 🐛(frontend) remove scroll listener table content #688 +- 🐛(frontend) remove scroll listener table content #688 - 🔒️(back) restrict access to favorite_list endpoint #690 -- 🐛(backend) refactor to fix filtering on children - and descendants views #695 +- 🐛(backend) refactor to fix filtering on children + and descendants views #695 - 🐛(action) fix notify-argocd workflow #713 - 🚨(helm) fix helmfile lint #736 - 🚚(frontend) redirect to 401 page when 401 error #759 - ## [2.4.0] - 2025-03-06 ## Added @@ -195,7 +191,6 @@ and this project adheres to - 🐛(frontend) fix collaboration error #684 - ## [2.3.0] - 2025-03-03 ## Added diff --git a/src/frontend/apps/impress/.env b/src/frontend/apps/impress/.env index bcf7592f3..dd7b96ac4 100644 --- a/src/frontend/apps/impress/.env +++ b/src/frontend/apps/impress/.env @@ -1,3 +1,4 @@ NEXT_PUBLIC_API_ORIGIN= NEXT_PUBLIC_SW_DEACTIVATED= NEXT_PUBLIC_PUBLISH_AS_MIT=true +NEXT_PUBLIC_GRIST_API_KEY= diff --git a/src/frontend/apps/impress/.env.development b/src/frontend/apps/impress/.env.development index 248c72654..1e1a2d17d 100644 --- a/src/frontend/apps/impress/.env.development +++ b/src/frontend/apps/impress/.env.development @@ -1,3 +1,4 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:8071 NEXT_PUBLIC_PUBLISH_AS_MIT=false NEXT_PUBLIC_SW_DEACTIVATED=true +NEXT_PUBLIC_GRIST_API_KEY= diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 8fb4dc3c5..d63ee8f42 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -32,6 +32,7 @@ "@react-pdf/renderer": "4.3.0", "@sentry/nextjs": "9.26.0", "@tanstack/react-query": "5.80.5", + "ag-grid-react": "^33.3.1", "canvg": "4.0.3", "clsx": "2.1.1", "cmdk": "1.1.1", diff --git a/src/frontend/apps/impress/src/api/config.ts b/src/frontend/apps/impress/src/api/config.ts index 916585e60..f4edb5d4d 100644 --- a/src/frontend/apps/impress/src/api/config.ts +++ b/src/frontend/apps/impress/src/api/config.ts @@ -20,3 +20,5 @@ export const backendUrl = () => */ export const baseApiUrl = (apiVersion: string = '1.0') => `${backendUrl()}/api/v${apiVersion}/`; + +export const gristApiUrl = () => 'http://localhost:8484/api/'; diff --git a/src/frontend/apps/impress/src/api/gristApi.ts b/src/frontend/apps/impress/src/api/gristApi.ts new file mode 100644 index 000000000..acf92ff0c --- /dev/null +++ b/src/frontend/apps/impress/src/api/gristApi.ts @@ -0,0 +1,16 @@ +import { gristApiUrl } from './config'; + +export const gristFetchApi = async (input: string, init?: RequestInit) => { + const apiUrl = `${gristApiUrl()}${input}`; + const bearerToken = `Bearer ${process.env.NEXT_PUBLIC_GRIST_API_KEY}`; + + const headers = { + 'Content-Type': 'application/json', + Authorization: bearerToken, + }; + + return await fetch(apiUrl, { + ...init, + headers, + }); +}; diff --git a/src/frontend/apps/impress/src/api/index.ts b/src/frontend/apps/impress/src/api/index.ts index 1d742adb8..2354dda3c 100644 --- a/src/frontend/apps/impress/src/api/index.ts +++ b/src/frontend/apps/impress/src/api/index.ts @@ -1,6 +1,7 @@ export * from './APIError'; export * from './config'; export * from './fetchApi'; +export * from './gristApi'; export * from './helpers'; export * from './types'; export * from './utils'; diff --git a/src/frontend/apps/impress/src/components/DropButton.tsx b/src/frontend/apps/impress/src/components/DropButton.tsx index 22f18f670..e1a82cccc 100644 --- a/src/frontend/apps/impress/src/components/DropButton.tsx +++ b/src/frontend/apps/impress/src/components/DropButton.tsx @@ -1,4 +1,5 @@ import { + CSSProperties, PropsWithChildren, ReactNode, useEffect, @@ -38,6 +39,7 @@ const StyledButton = styled(Button)` export interface DropButtonProps { button: ReactNode; buttonCss?: BoxProps['$css']; + buttonStyle?: CSSProperties; isOpen?: boolean; onOpenChange?: (isOpen: boolean) => void; label?: string; @@ -46,6 +48,7 @@ export interface DropButtonProps { export const DropButton = ({ button, buttonCss, + buttonStyle, isOpen = false, onOpenChange, children, @@ -77,6 +80,7 @@ export const DropButton = ({ ${buttonCss}; `} className="--docs--drop-button" + style={buttonStyle} > {button} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 9a486b586..5d91f3bf3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -10,6 +10,7 @@ import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; import { HocuspocusProvider } from '@hocuspocus/provider'; +import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import * as Y from 'yjs'; @@ -27,13 +28,16 @@ import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; -import { CalloutBlock, DividerBlock } from './custom-blocks'; +import { CalloutBlock, DatabaseBlock, DividerBlock } from './custom-blocks'; + +ModuleRegistry.registerModules([AllCommunityModule]); export const blockNoteSchema = withPageBreak( BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, callout: CalloutBlock, + database: DatabaseBlock, divider: DividerBlock, }, }), diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index 3122b1c17..26a65b9e0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -13,6 +13,7 @@ import { DocsBlockSchema } from '../types'; import { getCalloutReactSlashMenuItems, + getDatabaseReactSlashMenuItems, getDividerReactSlashMenuItems, } from './custom-blocks'; @@ -20,6 +21,7 @@ export const BlockNoteSuggestionMenu = () => { const editor = useBlockNoteEditor(); const { t } = useTranslation(); const basicBlocksName = useDictionary().slash_menu.page_break.group; + const advancedBlocksName = useDictionary().slash_menu.table.group; const getSlashMenuItems = useMemo(() => { return async (query: string) => @@ -29,12 +31,13 @@ export const BlockNoteSuggestionMenu = () => { getDefaultReactSlashMenuItems(editor), getPageBreakReactSlashMenuItems(editor), getCalloutReactSlashMenuItems(editor, t, basicBlocksName), + getDatabaseReactSlashMenuItems(editor, t, advancedBlocksName), getDividerReactSlashMenuItems(editor, t, basicBlocksName), ), query, ), ); - }, [basicBlocksName, editor, t]); + }, [basicBlocksName, advancedBlocksName, editor, t]); return ( void; + allowCreateSource?: boolean; +}; + +export const DatabaseSelector = ({ + onDatabaseSelected, + allowCreateSource = false, +}: DatabaseSelectorProps) => { + const { createTable } = useGristCreateDocAndTable(); + const { currentDoc } = useDocStore(); + + const handleCreateNewDatabase = () => { + if (!currentDoc) { + console.error('No current document found to create a new database.'); + return; + } + createTable(currentDoc.title ?? currentDoc.id) + .then(({ documentId, tableId }) => { + onDatabaseSelected({ documentId, tableId }); + }) + .catch((error) => { + console.error('Error creating new database:', error); + }); + }; + + return ( + + + + + Source de données + {allowCreateSource && ( + Choisissez votre méthode de création + )} + + {allowCreateSource && ( + <> + + ou + + )} + + + + ); +}; + +const Wrapper = styled(Box)` + border: 2px solid rgb(160, 207, 255); + background-color: rgb(230, 243, 255); + border-radius: 4px; + width: 100%; + padding: 16px; + align-items: center; + gap: 10px; +`; + +const Title = styled(Text)` + font-weight: 800; + font-size: 18px; +`; + +const Description = styled(Text)` + color: rgb(110, 110, 110); + font-size: 14px; +`; + +const OptionTitle = styled(Text)` + font-weight: 600; + font-size: 14px; +`; + +const Option = styled(Box)` + width: 100%; + border: 1px solid rgb(180, 180, 180); + border-radius: 4px; + padding: 8px 16px; +`; + +const OptionsWrapper = styled(Box)` + width: 100%; + gap: 5px; + align-items: center; +`; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DatabaseSourceSelector.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DatabaseSourceSelector.tsx new file mode 100644 index 000000000..097a577b1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DatabaseSourceSelector.tsx @@ -0,0 +1,78 @@ +import { Spinner } from '@gouvfr-lasuite/ui-kit'; +import { Select } from '@openfun/cunningham-react'; +import { useState } from 'react'; + +import { Box, Text } from '@/components'; +import { useListGristTables } from '@/features/grist'; +import { Doc, useListGristDocs } from '@/features/grist/useListGristDocs'; + +type DatabaseSourceSelectorProps = { + onSourceSelected: (args: { documentId: string; tableId: string }) => void; +}; + +const TableSelector = ({ + documentId, + onSourceSelected, +}: { documentId: number } & DatabaseSourceSelectorProps) => { + const { tables, isLoading } = useListGristTables(documentId); + if (tables) { + return ( + ({ + label: doc.name, + value: doc.id.toString(), + render: () => {doc.name}, + }))} + onChange={(e) => + setSelectedDoc( + docs.find((doc) => doc.id.toString() === e.target.value), + ) + } + /> + {selectedDoc && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DatabaseTableDisplay.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DatabaseTableDisplay.tsx new file mode 100644 index 000000000..931ca0346 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DatabaseTableDisplay.tsx @@ -0,0 +1,16 @@ +import { useGristTableData } from '@/features/grist/useGristTableData'; + +export const DatabaseTableDisplay = ({ + documentId, + tableId, +}: { + documentId: string; + tableId: string; +}) => { + const { tableData } = useGristTableData({ + documentId, + tableId, + }); + + return JSON.stringify(tableData, null, 2); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock.tsx new file mode 100644 index 000000000..c141dd991 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock.tsx @@ -0,0 +1,86 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { insertOrUpdateBlock } from '@blocknote/core'; +import { createReactBlockSpec } from '@blocknote/react'; +import { TFunction } from 'i18next'; +import React from 'react'; + +import { Box, Icon } from '@/components'; + +import { DocsBlockNoteEditor } from '../../types'; +import { DatabaseSelector } from '../DatabaseSelector'; + +import { DatabaseGrid } from './DatabaseBlock/DatabaseGrid'; + +export const DatabaseBlock = createReactBlockSpec( + { + type: 'database', + propSchema: { + documentId: { + type: 'string', + default: '', + }, + tableId: { + type: 'string', + default: '', + }, + }, + content: 'none', + isSelectable: false, + }, + { + render: ({ block, editor }) => { + return ( + + {block.props.documentId && block.props.tableId ? ( + + + + ) : ( + { + editor.updateBlock(block, { + props: { documentId: documentId.toString(), tableId }, + }); + }} + allowCreateSource + /> + )} + + ); + }, + }, +); + +export const getDatabaseReactSlashMenuItems = ( + editor: DocsBlockNoteEditor, + t: TFunction<'translation', undefined>, + group: string, +) => [ + { + title: t('Database'), + onItemClick: () => { + insertOrUpdateBlock(editor, { + type: 'database', + }); + }, + aliases: ['database', 'db', 'base de données', 'grist'], + group, + icon: , + subtext: t('Create database view synced with Grist'), + }, +]; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/AddColumnButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/AddColumnButton.tsx new file mode 100644 index 000000000..06002d98d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/AddColumnButton.tsx @@ -0,0 +1,63 @@ +import { Button, Input } from '@openfun/cunningham-react'; +import { useState } from 'react'; + +import { Box, DropButton, Text } from '@/components'; + +export const AddButtonComponent = ({ + addColumn, +}: { + addColumn: (columnName: string) => void; +}) => { + const [isOpen, setIsOpen] = useState(false); + const onOpenChange = (open: boolean) => { + setIsOpen(open); + }; + const [columnName, setColumnName] = useState(''); + + return ( + + add + + } + > + + + Ajouter une colonne + + { + setColumnName(event.target.value); + }} + > + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/AddRowButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/AddRowButton.tsx new file mode 100644 index 000000000..52fdde591 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/AddRowButton.tsx @@ -0,0 +1,63 @@ +import { Button } from '@openfun/cunningham-react'; +import { Dispatch, SetStateAction } from 'react'; + +import { useGristCrudRecords } from '@/features/grist/useGristCrudRecords'; + +import { DatabaseRow } from './types'; +import { createNewRow } from './utils'; + +export const AddRowButton = ({ + columns, + setRowData, + documentId, + tableId, +}: { + documentId: string; + tableId: string; + columns: string[]; + setRowData: Dispatch>; +}) => { + const { createRecords } = useGristCrudRecords(); + + const addRow = () => { + const newRow = createNewRow({ columnNames: columns }); + setRowData((prev: DatabaseRow[] | undefined) => { + if (prev === undefined) { + return [newRow]; + } + const updatedRows = [...prev]; + // Insert at the second-to-last position + updatedRows.splice(updatedRows.length - 1, 0, newRow); + return updatedRows; + }); + + void createRecords(documentId, tableId, [{ fields: newRow }]); + }; + + const color = '#817E77'; + return ( + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/DatabaseGrid.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/DatabaseGrid.tsx new file mode 100644 index 000000000..36185f302 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/DatabaseGrid.tsx @@ -0,0 +1,177 @@ +import { + CellEditingStoppedEvent, + ColDef, + ColSpanParams, + ICellRendererParams, +} from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; +import { useCallback, useEffect, useMemo } from 'react'; + +import { Box } from '@/components'; +import { + ColumnType, + useGristCrudColumns, + useGristCrudRecords, + useGristTableData, +} from '@/features/grist'; + +import { AddButtonComponent } from './AddColumnButton'; +import { useColumns, useRows } from './hooks'; +import { DatabaseRow } from './types'; +import { + ADD_NEW_ROW, + addRowCellRenderer, + createNewRow, + defaultColDef, + getColumnNames, + newRowColSpan, +} from './utils'; + +export const DatabaseGrid = ({ + documentId, + tableId, +}: { + documentId: string; + tableId: string; +}) => { + const { tableData } = useGristTableData({ + documentId, + tableId, + }); + + const { createColumns } = useGristCrudColumns(); + const { updateRecords } = useGristCrudRecords(); + + const { rowData, setRowData } = useRows(); + const { colDefs, setColDefs } = useColumns(); + + useEffect(() => { + const filteredEntries = Object.entries(tableData).filter( + ([key]) => key !== 'manualSort', + ); + + const rowData1: DatabaseRow[] = []; + + const numRows = filteredEntries[0]?.[1].length; + + for (let i = 0; i < numRows; i++) { + const row: DatabaseRow = {}; + for (const [key, values] of filteredEntries) { + row[key] = values[i] ?? ''; + } + rowData1.push(row); + } + + setRowData(rowData1); + + const columnNames = Object.keys(Object.fromEntries(filteredEntries)); + + const columns: ColDef[] = columnNames.map((key) => ({ + field: key, + hide: key === 'id', + colSpan: (params: ColSpanParams, unknown>) => + newRowColSpan(params, columnNames.length + 1), + cellRendererSelector: ( + params: ICellRendererParams>, + ) => + addRowCellRenderer({ + params, + columnNames, + setRowData, + documentId, + tableId, + }), + })); + + setColDefs(columns); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableData]); + + const addNewRow = useMemo(() => { + const columnNames = getColumnNames(colDefs); + const lastRow = rowData?.[rowData.length - 1]; + if (lastRow && Object.values(lastRow).length > 0) { + const lastRowValue = Object.values(lastRow)[0]; + if (lastRowValue === ADD_NEW_ROW) { + return; + } + } + const newRow = createNewRow({ value: ADD_NEW_ROW, columnNames }); + + return newRow; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colDefs]); + + const addColumn = (columnName: string) => { + const columnNames = getColumnNames(colDefs); + const newColDef: ColDef = { + field: columnName, + colSpan: (params: ColSpanParams, unknown>) => + newRowColSpan(params, columnNames.length + 1), + cellRendererSelector: ( + params: ICellRendererParams>, + ) => + addRowCellRenderer({ + documentId, + tableId, + params, + columnNames, + setRowData, + }), + }; + + setColDefs((prev) => { + return [...(prev ?? []), newColDef]; + }); + + void createColumns(documentId, tableId, [ + { + id: columnName, + fields: { + label: columnName, + type: ColumnType.TEXT, + }, + }, + ]); + }; + + const onCellEditingStopped = useCallback( + (event: CellEditingStoppedEvent) => { + const { oldValue, newValue, data } = event; + + if (data === undefined) { + return; + } + const { id: rowId, ...updatedRow } = data; + + if (!(typeof rowId === 'number') || oldValue === newValue) { + return; + } + + void updateRecords(documentId, tableId, [ + { id: rowId, fields: updatedRow }, + ]); + }, + // disable updateRecords + // eslint-disable-next-line react-hooks/exhaustive-deps + [documentId, tableId], + ); + + return ( + <> + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/hooks.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/hooks.ts new file mode 100644 index 000000000..179b9d4db --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/hooks.ts @@ -0,0 +1,16 @@ +import { ColDef } from 'ag-grid-community'; +import { useState } from 'react'; + +import { DatabaseRow } from './types'; + +export const useColumns = () => { + const [colDefs, setColDefs] = useState(); + + return { colDefs, setColDefs }; +}; + +export const useRows = () => { + const [rowData, setRowData] = useState(); + + return { rowData, setRowData }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/types.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/types.ts new file mode 100644 index 000000000..f01a3c5ca --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/types.ts @@ -0,0 +1,6 @@ +type IdColumn = { + id: number; +}; +export type DatabaseRow = + | Record + | IdColumn; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/utils.ts new file mode 100644 index 000000000..6f85b269b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/utils.ts @@ -0,0 +1,83 @@ +import { + ColDef, + ColSpanParams, + ICellRendererParams, + SizeColumnsToContentStrategy, +} from 'ag-grid-community'; +import { Dispatch, SetStateAction } from 'react'; + +import { AddRowButton } from './AddRowButton'; +import { DatabaseRow } from './types'; + +export const ADD_NEW_ROW = 'add-new-row'; + +export const autoSizeStrategy: SizeColumnsToContentStrategy = { + type: 'fitCellContents', +}; + +export const defaultColDef = { + flex: 1, + filter: true, + editable: true, + unSortIcon: true, + minWidth: 200, +}; + +export const createNewRow = ({ + columnNames, + value = undefined, +}: { + value?: string; + columnNames: string[] | undefined; +}) => { + const addNewRow: DatabaseRow = {}; + columnNames?.forEach((name) => { + if (name !== undefined) { + addNewRow[name] = value; + } + }); + + return addNewRow; +}; + +export const addRowCellRenderer = ({ + params, + columnNames, + setRowData, + documentId, + tableId, +}: { + params: ICellRendererParams>; + columnNames: string[] | undefined; + setRowData: Dispatch>; + documentId: string; + tableId: string; +}) => { + if (params.data) { + const addRowButton = { + component: AddRowButton, + params: { columns: columnNames, setRowData, documentId, tableId }, + }; + if (Object.values(params.data)[0] === ADD_NEW_ROW) { + return addRowButton; + } + return undefined; + } + return undefined; +}; + +export const newRowColSpan = ( + params: ColSpanParams>, + columnNumber: number, +) => { + const colsValues = params.data ?? {}; + const isNewRow = Object.values(colsValues)[0] === ADD_NEW_ROW; + if (isNewRow) { + return columnNumber; + } + + return 1; +}; + +export const getColumnNames = (colDefs: ColDef[] | undefined) => + (colDefs ?? []).map((col) => col.field).filter((col) => col !== undefined); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts index 34a8c459c..400fb28e3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts @@ -1,2 +1,3 @@ export * from './CalloutBlock'; export * from './DividerBlock'; +export * from './DatabaseBlock'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/databaseDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/databaseDocx.tsx new file mode 100644 index 000000000..44bf450d1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/databaseDocx.tsx @@ -0,0 +1,22 @@ +import { Paragraph, TextRun } from 'docx'; + +import { DocsExporterDocx } from '../types'; +import { docxBlockPropsToStyles } from '../utils'; + +export const blockMappingDatabaseDocx: DocsExporterDocx['mappings']['blockMapping']['database'] = + (block, exporter) => { + return new Paragraph({ + ...docxBlockPropsToStyles({}, exporter.options.colors), + spacing: { before: 10, after: 10 }, + children: [ + new TextRun({ + text: ' ', + break: 1, + }), + new TextRun({ + text: ' ', + break: 1, + }), + ], + }); + }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/databasePDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/databasePDF.tsx new file mode 100644 index 000000000..85ea55e79 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/databasePDF.tsx @@ -0,0 +1,31 @@ +import { StyleSheet, Text, View } from '@react-pdf/renderer'; + +import { DocsExporterPDF } from '../types'; + +const styles = StyleSheet.create({ + wrapper: { + width: '100%', + display: 'flex', + flexDirection: 'row', + padding: 8, + gap: 4, + }, + emoji: { + fontSize: 16, + }, + text: { + maxWidth: '94%', + paddingTop: 2, + }, +}); + +export const blockMappingDatabasePDF: DocsExporterPDF['mappings']['blockMapping']['database'] = + () => { + return ( + + + {' '} + + + ); + }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts index e3e766dbc..a81fccf3c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts @@ -1,5 +1,7 @@ export * from './calloutDocx'; export * from './calloutPDF'; +export * from './databaseDocx'; +export * from './databasePDF'; export * from './dividerDocx'; export * from './dividerPDF'; export * from './headingPDF'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx index 434daa995..22db0f1dd 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx @@ -1,7 +1,9 @@ +import { BlockMapping } from '@blocknote/core'; import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter'; import { blockMappingCalloutDocx, + blockMappingDatabaseDocx, blockMappingDividerDocx, blockMappingImageDocx, blockMappingQuoteDocx, @@ -14,7 +16,8 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { ...docxDefaultSchemaMappings.blockMapping, callout: blockMappingCalloutDocx, divider: blockMappingDividerDocx, + database: blockMappingDatabaseDocx, quote: blockMappingQuoteDocx, image: blockMappingImageDocx, - }, + } as BlockMapping, // eslint-disable-line @typescript-eslint/no-explicit-any }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx index 4224045d1..9ea0fa34c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx @@ -1,7 +1,9 @@ +import { BlockMapping } from '@blocknote/core'; import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter'; import { blockMappingCalloutPDF, + blockMappingDatabasePDF, blockMappingDividerPDF, blockMappingHeadingPDF, blockMappingImagePDF, @@ -16,11 +18,12 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = { blockMapping: { ...pdfDefaultSchemaMappings.blockMapping, callout: blockMappingCalloutPDF, + database: blockMappingDatabasePDF, // TODO: create Db block heading: blockMappingHeadingPDF, image: blockMappingImagePDF, paragraph: blockMappingParagraphPDF, divider: blockMappingDividerPDF, quote: blockMappingQuotePDF, table: blockMappingTablePDF, - }, + } as BlockMapping, // eslint-disable-line @typescript-eslint/no-explicit-any }; diff --git a/src/frontend/apps/impress/src/features/grist/index.ts b/src/frontend/apps/impress/src/features/grist/index.ts new file mode 100644 index 000000000..2eeaeeb64 --- /dev/null +++ b/src/frontend/apps/impress/src/features/grist/index.ts @@ -0,0 +1,5 @@ +export * from './useGristTableData'; +export * from './useListGristDocs'; +export * from './useListGristTables'; +export * from './useGristCrudRecords'; +export * from './useGristCrudColumns'; diff --git a/src/frontend/apps/impress/src/features/grist/useGristCreateTable.ts b/src/frontend/apps/impress/src/features/grist/useGristCreateTable.ts new file mode 100644 index 000000000..21484a7fe --- /dev/null +++ b/src/frontend/apps/impress/src/features/grist/useGristCreateTable.ts @@ -0,0 +1,64 @@ +import { APIError, errorCauses, gristFetchApi } from '@/api'; + +import { TableDescription } from './useListGristTables'; + +export const useGristCreateDocAndTable = () => { + const createTable = async ( + name: string, + ): Promise<{ + documentId: string; + tableId: string; + }> => { + const DEFAULT_WORKSPACE_ID = 2; + const docUrl = `workspaces/${DEFAULT_WORKSPACE_ID}/docs`; + try { + const docResponse = await gristFetchApi(docUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }); + + if (!docResponse.ok) { + throw new APIError( + 'Failed to fetch Grist tables', + await errorCauses(docResponse), + ); + } + + const documentId = (await docResponse.json()) as string; + + const tableUrl = `docs/${documentId}/tables`; + const tableResponse = await gristFetchApi(tableUrl); + if (!tableResponse.ok) { + throw new APIError( + 'Failed to fetch Grist tables', + await errorCauses(tableResponse), + ); + } + + const tableDescription = (await tableResponse.json()) as TableDescription; + + if (tableDescription.tables.length === 0) { + throw new Error('No tables found in the created document'); + } + + if (tableDescription.tables.length > 1) { + throw new Error( + 'More than one table has been found in the created document, this should not happen.', + ); + } + + return { + documentId, + tableId: tableDescription.tables[0].id, + }; + } catch (error) { + console.error('Error creating Grist table:', error); + throw error; + } + }; + + return { createTable }; +}; diff --git a/src/frontend/apps/impress/src/features/grist/useGristCrudColumns.ts b/src/frontend/apps/impress/src/features/grist/useGristCrudColumns.ts new file mode 100644 index 000000000..0cd79d85e --- /dev/null +++ b/src/frontend/apps/impress/src/features/grist/useGristCrudColumns.ts @@ -0,0 +1,71 @@ +import { gristFetchApi } from '@/api'; + +export enum ColumnType { + TEXT = 'Text', + NUMBER = 'Numeric', + BOOLEAN = 'Bool', +} + +type ColumnInput = { + type: ColumnType; + label: string; +}; + +export const useGristCrudColumns = () => { + const createColumns = async ( + documentId: string, + tableId: string, + columns: { id: string; fields: ColumnInput }[], + ) => { + const url = `docs/${documentId}/tables/${tableId}/columns`; + try { + const response = await gristFetchApi(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ columns }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Failed to create columns: ${response.status} ${response.statusText} - ${errorBody}`, + ); + } + + return (await response.json()) as Promise<{ records: { id: string }[] }>; + } catch (error) { + console.error('Error creating Grist record:', error); + throw error; + } + }; + + const deleteColumns = async ( + documentId: string, + tableId: string, + columnId: string, + ) => { + const url = `docs/${documentId}/tables/${tableId}/columns/${columnId}`; + try { + const response = await gristFetchApi(url, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Failed to delete column: ${response.status} ${response.statusText} - ${errorBody}`, + ); + } + } catch (error) { + console.error('Error deleting Grist column:', error); + throw error; + } + }; + + return { createColumns, deleteColumns }; +}; diff --git a/src/frontend/apps/impress/src/features/grist/useGristCrudRecords.ts b/src/frontend/apps/impress/src/features/grist/useGristCrudRecords.ts new file mode 100644 index 000000000..e90715d03 --- /dev/null +++ b/src/frontend/apps/impress/src/features/grist/useGristCrudRecords.ts @@ -0,0 +1,88 @@ +import { gristFetchApi } from '@/api'; + +export const useGristCrudRecords = () => { + const createRecords = async ( + documentId: string, + tableId: string, + records: { fields: unknown }[], + ) => { + const url = `docs/${documentId}/tables/${tableId}/records`; + try { + const response = await gristFetchApi(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ records }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Failed to create record: ${response.status} ${response.statusText} - ${errorBody}`, + ); + } + + return (await response.json()) as Promise<{ records: { id: string }[] }>; + } catch (error) { + console.error('Error creating Grist record:', error); + throw error; + } + }; + + const deleteRecords = async ( + documentId: string, + tableId: string, + recordIds: number[], + ) => { + const url = `docs/${documentId}/tables/${tableId}/data/delete`; + try { + const response = await gristFetchApi(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(recordIds), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Failed to delete records: ${response.status} ${response.statusText} - ${errorBody}`, + ); + } + } catch (error) { + console.error('Error deleting Grist records:', error); + throw error; + } + }; + + const updateRecords = async ( + documentId: string, + tableId: string, + records: { id: number; fields: unknown }[], + ) => { + const url = `docs/${documentId}/tables/${tableId}/records`; + try { + const response = await gristFetchApi(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ records }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Failed to update records: ${response.status} ${response.statusText} - ${errorBody}`, + ); + } + } catch (error) { + console.error('Error updating Grist records:', error); + throw error; + } + }; + + return { createRecords, deleteRecords, updateRecords }; +}; diff --git a/src/frontend/apps/impress/src/features/grist/useGristTableData.ts b/src/frontend/apps/impress/src/features/grist/useGristTableData.ts new file mode 100644 index 000000000..531b6cf35 --- /dev/null +++ b/src/frontend/apps/impress/src/features/grist/useGristTableData.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; + +import { APIError, errorCauses, gristFetchApi } from '@/api'; + +export type UseGristTableDataArguments = { + documentId: string; + tableId: string; +}; + +export const useGristTableData = ({ + documentId, + tableId, +}: UseGristTableDataArguments) => { + const [tableData, setTableData] = useState< + Record + >({}); + + useEffect(() => { + const fetchData = async () => { + const url = `docs/${documentId}/tables/${tableId}/data`; + const response = await gristFetchApi(url); + if (!response.ok) { + throw new APIError( + 'Failed to fetch Grist table data', + await errorCauses(response), + ); + } + return (await response.json()) as Promise; + }; + + fetchData() + .then((res) => { + setTableData(res as Record); + }) + .catch((error) => { + console.error('Error fetching Grist table data:', error); + }); + }, [documentId, tableId]); + return { + tableData, + }; +}; diff --git a/src/frontend/apps/impress/src/features/grist/useListGristDocs.ts b/src/frontend/apps/impress/src/features/grist/useListGristDocs.ts new file mode 100644 index 000000000..9c97eba6b --- /dev/null +++ b/src/frontend/apps/impress/src/features/grist/useListGristDocs.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; + +import { APIError, errorCauses, gristFetchApi } from '@/api'; + +export interface Workspace { + id: number; + name: string; + access: string; + docs: Doc[]; + org: Org; +} + +export interface Doc { + id: number; + name: string; + access: string; + isPinned: boolean; + urlId: null; +} + +export interface Org { + id: number; + name: string; + domain: string; + owner: Owner; + access: string; + createdAt: Date; + updatedAt: Date; +} + +export interface Owner { + id: number; + name: string; + picture: null; +} + +export const useListGristDocs = (): { docs: Doc[] } => { + const [docs, setDocs] = useState([]); + + const fetchDocs = async () => { + const DEFAULT_WORKSPACE_ID = 2; + const url = `workspaces/${DEFAULT_WORKSPACE_ID}`; + const response = await gristFetchApi(url); + if (!response.ok) { + throw new APIError( + 'Failed to fetch Grist documents', + await errorCauses(response), + ); + } + return (await response.json()) as Promise; + }; + + useEffect(() => { + fetchDocs() + .then((workspace) => { + setDocs(workspace.docs); + }) + .catch((error) => { + console.error('Error fetching Grist documents:', error); + }); + }, []); + + return { + docs, + }; +}; diff --git a/src/frontend/apps/impress/src/features/grist/useListGristTables.ts b/src/frontend/apps/impress/src/features/grist/useListGristTables.ts new file mode 100644 index 000000000..5caeee23d --- /dev/null +++ b/src/frontend/apps/impress/src/features/grist/useListGristTables.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, gristFetchApi } from '@/api'; + +export interface TableDescription { + tables: Table[]; +} + +export interface Table { + id: string; + fields: Fields; +} + +export interface Fields { + tableRef: number; + onDemand: boolean; +} + +const listTables = async (documentId: number) => { + const url = `docs/${documentId}/tables`; + const response = await gristFetchApi(url); + + if (!response.ok) { + throw new APIError( + 'Failed to fetch Grist tables', + await errorCauses(response), + ); + } + + const tableDescription = (await response.json()) as TableDescription; + return tableDescription.tables; +}; + +type UseListGristTablesReturnType = { + tables: Table[] | undefined; + isLoading: boolean; +}; + +export const useListGristTables = ( + documentId: number, +): UseListGristTablesReturnType => { + const { data: tables, isLoading } = useQuery({ + queryKey: ['listTables', documentId], + queryFn: () => listTables(documentId), + }); + + return { + tables, + isLoading, + }; +}; diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index 24985d1b2..cfe4c3a94 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -251,6 +251,7 @@ "Banner image": "Imagen de portada", "Beautify": "Embellecer", "Callout": "Destacado", + "Database": "Database", "Can't load this page, please check your internet connection.": "No se puede cargar esta página, por favor compruebe su conexión a Internet.", "Cancel": "Cancelar", "Close the modal": "Cerrar modal", @@ -451,6 +452,8 @@ "Copy as {{format}}": "Copier en {{format}}", "Copy link": "Copier le lien", "Correct": "Corriger", + "Create database view synced with Grist": "Créer une base de données synchronisée avec Grist", + "Database": "Base de données", "Delete": "Supprimer", "Delete a doc": "Supprimer un doc", "Delete document": "Supprimer le document", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index f2757f1ac..39eb0bc40 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -7121,6 +7121,26 @@ acorn@^8.1.0, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.4.1, acorn@^8.8.1, acorn@^8 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== +ag-charts-types@11.3.1: + version "11.3.1" + resolved "https://registry.yarnpkg.com/ag-charts-types/-/ag-charts-types-11.3.1.tgz#5664cb61ecb3c8318e22bd1eb60cc4c4dfbb261e" + integrity sha512-kUSSypwsmkQDT5Oqgcr3zYQShzQuhvYeHaT1l2ocUrz3hBzkIiYwzp9CVG3Vs//M9TUCJLTcR0CBIr9ADhPITw== + +ag-grid-community@33.3.1: + version "33.3.1" + resolved "https://registry.yarnpkg.com/ag-grid-community/-/ag-grid-community-33.3.1.tgz#598a3322dda944455d0ca69ce3a8f32403b0a0cb" + integrity sha512-c3JXKY5K5V5KVZ6rhFc13hK/8UzaZNwTqL70YKkqUbqwOcogDVVeG4I25W6o5YOlFO6LFN/5xFnFPASWn4LwBw== + dependencies: + ag-charts-types "11.3.1" + +ag-grid-react@^33.3.1: + version "33.3.1" + resolved "https://registry.yarnpkg.com/ag-grid-react/-/ag-grid-react-33.3.1.tgz#b6c10e922b493c79408e09335bb30c91135a1aea" + integrity sha512-CHuuHxzuz2rsKuyxjuGnBadwm0/I2DCG4j1WgIa2wJkdlTUrcKjF6RD9qwfRLYKA4P40shG640emVuU6/qiZLA== + dependencies: + ag-grid-community "33.3.1" + prop-types "^15.8.1" + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"