diff --git a/CHANGELOG.md b/CHANGELOG.md index c831e4eec..a91aef717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - ✨(backend) allow setting session cookie age via env var #977 - ✨(backend) allow theme customnization using a configuration file #948 - ✨(frontend) Add a custom callout block to the editor #892 +- ✨(frontend) Add a custom database block to the editor #892 - 🚩(frontend) version MIT only #911 - ✨(backend) integrate maleware_detection from django-lasuite #936 - 🏗️(frontend) Footer configurable #959 diff --git a/src/frontend/apps/impress/.env b/src/frontend/apps/impress/.env index bcf7592f3..feed422ac 100644 --- a/src/frontend/apps/impress/.env +++ b/src/frontend/apps/impress/.env @@ -1,3 +1,3 @@ NEXT_PUBLIC_API_ORIGIN= NEXT_PUBLIC_SW_DEACTIVATED= -NEXT_PUBLIC_PUBLISH_AS_MIT=true +NEXT_PUBLIC_PUBLISH_AS_MIT=true \ No newline at end of file diff --git a/src/frontend/apps/impress/.env.development b/src/frontend/apps/impress/.env.development index 248c72654..c11bcfb13 100644 --- a/src/frontend/apps/impress/.env.development +++ b/src/frontend/apps/impress/.env.development @@ -1,3 +1,3 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:8071 NEXT_PUBLIC_PUBLISH_AS_MIT=false -NEXT_PUBLIC_SW_DEACTIVATED=true +NEXT_PUBLIC_SW_DEACTIVATED=true \ No newline at end of file diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index c111d1a40..b1ab905bc 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -32,7 +32,9 @@ "@react-pdf/renderer": "4.3.0", "@sentry/nextjs": "9.22.0", "@tanstack/react-query": "5.77.1", + "ag-grid-react": "^33.3.1", "canvg": "4.0.3", + "chart.js": "^4.4.9", "clsx": "2.1.1", "cmdk": "1.1.1", "crisp-sdk-web": "1.0.25", @@ -44,15 +46,22 @@ "lodash": "4.17.21", "luxon": "3.6.1", "next": "15.3.2", + "plotly": "^1.0.6", + "plotly.js": "^3.0.1", "posthog-js": "1.246.0", "react": "*", "react-aria-components": "1.9.0", + "react-chart-editor": "^0.46.1", + "react-chartjs-2": "^5.3.0", "react-dom": "*", "react-i18next": "15.5.2", "react-intersection-observer": "9.16.0", "react-select": "5.10.1", "styled-components": "6.1.18", "use-debounce": "10.0.4", + "vega": "^6.1.2", + "vega-embed": "^7.0.2", + "vega-lite": "^6.1.0", "y-protocols": "1.0.6", "yjs": "*", "zustand": "5.0.5" 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..2cddbed13 --- /dev/null +++ b/src/frontend/apps/impress/src/api/gristApi.ts @@ -0,0 +1,17 @@ +import { gristApiUrl } from './config'; + +export const gristFetchApi = async (input: string, init?: RequestInit) => { + const apiUrl = `${gristApiUrl()}${input}`; + const apiKey = localStorage.getItem('grist_api_key'); + const bearerToken = `Bearer ${apiKey}`; + + 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 7ed06af1d..400b3c3ee 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,14 +28,26 @@ import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; -import { CalloutBlock, DividerBlock } from './custom-blocks'; + +import { + CalloutBlock, + DatabaseBlock, + DividerBlock, + ReactEmbedBlock, + GristChartBlock, +} from './custom-blocks'; + +ModuleRegistry.registerModules([AllCommunityModule]); export const blockNoteSchema = withPageBreak( BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, callout: CalloutBlock, + database: DatabaseBlock, divider: DividerBlock, + embed: ReactEmbedBlock, + grist_chart: GristChartBlock, }, }), ); 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..38131bf29 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,13 +13,17 @@ import { DocsBlockSchema } from '../types'; import { getCalloutReactSlashMenuItems, + getDatabaseReactSlashMenuItems, getDividerReactSlashMenuItems, + getEmbedReactSlashMenuItems, + getGristChartReactSlashMenuItems, } from './custom-blocks'; 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 +33,15 @@ export const BlockNoteSuggestionMenu = () => { getDefaultReactSlashMenuItems(editor), getPageBreakReactSlashMenuItems(editor), getCalloutReactSlashMenuItems(editor, t, basicBlocksName), + getDatabaseReactSlashMenuItems(editor, t, advancedBlocksName), getDividerReactSlashMenuItems(editor, t, basicBlocksName), + getEmbedReactSlashMenuItems(editor, t, advancedBlocksName), + getGristChartReactSlashMenuItems(editor, t, basicBlocksName), ), query, ), ); - }, [basicBlocksName, editor, t]); + }, [basicBlocksName, advancedBlocksName, editor, t]); return ( void; +}; + +export const DatabaseSelector = ({ + onDatabaseSelected, +}: 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 ( + + + ou + + + ); +}; 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..f7c29cfb9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DatabaseSourceSelector.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; + +import { Box, DropdownMenu, Text } from '@/components'; +import { useListGristTables } from '@/features/grist'; +import { Doc, useListGristDocs } from '@/features/grist/useListGristDocs'; + +type DatabaseSourceSelectorProps = { + onSourceSelected: (args: { documentId: number; tableId: string }) => void; +}; + +const TableSelector = ({ + documentId, + onSourceSelected, +}: { documentId: number } & DatabaseSourceSelectorProps) => { + const { tables } = useListGristTables(documentId); + return tables ? ( + ({ + label: id, + value: id, + callback: () => onSourceSelected({ documentId, tableId: id }), + }))} + showArrow + > + Sélectionner une table Grist existante + + ) : ( + + No tables available + + ); +}; + +export const DatabaseSourceSelector = ({ + onSourceSelected, +}: DatabaseSourceSelectorProps) => { + const [selectedDoc, setSelectedDoc] = useState(); + const { docs } = useListGristDocs(); + + return ( + + ({ + label: doc.name, + value: doc.id, + callback: () => setSelectedDoc(doc), + }))} + showArrow + > + {selectedDoc?.name ?? 'Sélectionner un document Grist'} + + {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..241ce5533 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock.tsx @@ -0,0 +1,117 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { insertOrUpdateBlock } from '@blocknote/core'; +import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react'; +import { TFunction } from 'i18next'; +import { useState } from 'react'; + +import { Box, Icon } from '@/components'; + +import { DocsBlockNoteEditor } from '../../types'; +import { DatabaseSelector } from '../DatabaseSelector'; + +import { DatabaseGrid } from './DatabaseBlock/DatabaseGrid'; +import { GristApiKeyModal } from './DatabaseBlock/GristApiKeyModal'; + +export const DatabaseBlock = createReactBlockSpec( + { + type: 'database', + propSchema: { + documentId: { + type: 'string', + default: '', + }, + tableId: { + type: 'string', + default: '', + }, + }, + content: 'inline', + }, + { + render: ({ block, editor }) => { + const getGristApiKey = (): string | null => { + return localStorage.getItem('grist_api_key'); + }; + + const [gristApiKey, setGristApiKey] = useState( + getGristApiKey, + ); + const [openGristApiKeyModal, setOpenGristApiKeyModal] = useState( + gristApiKey === null, + ); + + if (openGristApiKeyModal) { + return ( + + ); + } + + return ( + + {block.props.documentId && block.props.tableId ? ( + + + + ) : ( + { + editor.updateBlock(block, { + props: { documentId: documentId.toString(), tableId }, + }); + }} + /> + )} + + ); + }, + }, +); + +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ée'], + group, + icon: , + subtext: t('Create database view synced with Grist'), + }, +]; + +// TODO: remove if unused +export const getDatabaseFormattingToolbarItems = ( + t: TFunction<'translation', undefined>, +): BlockTypeSelectItem => ({ + name: t('Database'), + type: 'database', + icon: () => , + isSelected: (block) => block.type === 'database', +}); 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..d30d11261 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/DatabaseGrid.tsx @@ -0,0 +1,180 @@ +import { + CellEditingStoppedEvent, + ColDef, + ColSpanParams, + ICellRendererParams, +} from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; +import { useCallback, useEffect } 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]); + + useEffect(() => { + 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 addNewRow = createNewRow({ value: ADD_NEW_ROW, columnNames }); + setRowData((prev) => [...(prev ? prev : []), addNewRow]); + // 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 !== undefined ? prev : []), newColDef]; + }); + + void createColumns(documentId, tableId, [ + { + id: columnName, + fields: { + label: columnName, + type: ColumnType.TEXT, + }, + }, + ]); + }; + + const onCellEditingStopped = useCallback( + (event: CellEditingStoppedEvent) => { + const { oldValue, newValue, data } = event; + console.log('Cell editing stopped:', { + oldValue, + newValue, + data, + }); + + 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/GristApiKeyModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/GristApiKeyModal.tsx new file mode 100644 index 000000000..57c8fa960 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock/GristApiKeyModal.tsx @@ -0,0 +1,65 @@ +import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +export const GristApiKeyModal = ({ + isOpen, + setOpen, + gristApiKey, + setGristApiKey, +}: { + isOpen: boolean; + setOpen: (open: boolean) => void; + gristApiKey: string | null; + setGristApiKey: (key: string) => void; +}) => { + const { t: translation } = useTranslation(); + + const validateGristApiKey = (): void => { + if (gristApiKey !== null) { + localStorage.setItem('grist_api_key', gristApiKey); + } + setOpen(false); + }; + + return ( + setOpen(false)} + title={{translation('Grist API Key')}} + > + + + {translation( + 'To sync your data with Grist, you need to provide an API Key', + )} + + + {translation('How to find you API Key')}: + + 1. {translation('Connect to your Grist account')} + 2. {translation('Go to Profile settings > API > API Key')} + + 3. {translation('Create a new API Key and copy it')} + + { + const value = event.target.value; + setGristApiKey(value); + }} + /> + + + + ); +}; 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/EmbedBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/EmbedBlock.tsx new file mode 100644 index 000000000..35dcd9447 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/EmbedBlock.tsx @@ -0,0 +1,191 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { + insertOrUpdateBlock, + FileBlockConfig, + PropSchema, +} from '@blocknote/core'; +import { + BlockTypeSelectItem, + createReactBlockSpec, + ReactCustomBlockRenderProps, + ResizableFileBlockWrapper, +} from '@blocknote/react'; +import { TFunction } from 'i18next'; +import React, { useEffect, useRef, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, Icon } from '@/components'; + +import { DocsBlockNoteEditor } from '../../types'; + +export const iframePropSchema: PropSchema = { + url: { default: '' }, + caption: { default: '' }, + name: { default: '' }, + showPreview: { default: true }, + previewWidth: { default: 500 }, +}; + +export const iframeBlockConfig = { + type: 'embed' as const, + propSchema: iframePropSchema, + content: 'none', + isFileBlock: true, + fileBlockAccept: ['image/png'], +} satisfies FileBlockConfig; + +export const IFrameViewer = ( + props: ReactCustomBlockRenderProps, +) => { + const url = props.block.props.url; + const aspectRatio = props.block.props.aspectRatio || 16 / 9; + // const url = 'http://localhost:8484/o/docs/pmqLaKmSrf3h/Untitled-document/p/2'; + const [iframeError, setIframeError] = React.useState(false); + const containerRef = useRef(null); + const [isResizing, setIsResizing] = useState(false); + + useEffect(() => { + if (!containerRef.current) return; + + const wrapperEl = containerRef.current.closest( + '.bn-file-block-content-wrapper', + ); + + const startResizing = () => { + setIsResizing(true); + }; + const stopResizing = () => { + setIsResizing(false); + }; + + wrapperEl.addEventListener('pointerdown', startResizing); + document.addEventListener('pointerup', stopResizing); + + return () => { + wrapperEl.removeEventListener('pointerdown', startResizing); + document.removeEventListener('pointerdown', stopResizing); + }; + }, []); + + if (!url) { + return No URL provided for embed.; + } + + return !iframeError ? ( +
+