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..7486e2954 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= \ No newline at end of file diff --git a/src/frontend/apps/impress/.env.development b/src/frontend/apps/impress/.env.development index 248c72654..0a72d1bda 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= \ 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..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 7ed06af1d..9e0bb9ea6 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,25 @@ import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; -import { CalloutBlock, DividerBlock } from './custom-blocks'; +import { + CalloutBlock, + DatabaseBlock, + DividerBlock, + GristChartBlock, + ReactEmbedBlock, +} 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; + 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..32a09d91b --- /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..52d59b1a4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/DatabaseBlock.tsx @@ -0,0 +1,96 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { insertOrUpdateBlock } from '@blocknote/core'; +import { BlockTypeSelectItem, 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ée', 'grist'], + 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..c4d709332 --- /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 addNewRowRow = 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 addNewRow = createNewRow({ value: ADD_NEW_ROW, columnNames }); + + return 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; + + 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/EmbedBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/EmbedBlock.tsx new file mode 100644 index 000000000..4ff59465e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/EmbedBlock.tsx @@ -0,0 +1,226 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { + FileBlockConfig, + InlineContentSchema, + PropSchema, + StyleSchema, + insertOrUpdateBlock, +} from '@blocknote/core'; +import { + BlockTypeSelectItem, + ReactCustomBlockRenderProps, + ResizableFileBlockWrapper, + createReactBlockSpec, +} 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 & { + caption: { + default: ''; + }; + name: { + default: ''; + }; + url?: { + default: ''; + }; + showPreview?: { + default: boolean; + }; + previewWidth?: { + default: undefined; + type: 'number'; + }; +} = { + url: { default: '' }, + caption: { default: '' }, + name: { default: '' }, + showPreview: { default: true }, + previewWidth: { default: undefined, type: 'number' }, +}; + +export const iframeBlockConfig = { + type: 'embed' as const, + propSchema: iframePropSchema, + content: 'none', + isFileBlock: true, + fileBlockAccept: ['image/png'], +} satisfies FileBlockConfig; + +export const IFrameViewer = ( + props: ReactCustomBlockRenderProps< + typeof iframeBlockConfig, + InlineContentSchema, + StyleSchema + >, +) => { + const url = props.block.props.url; + const aspectRatio = props.block.props.aspectRatio || 16 / 9; + + const [iframeError, setIframeError] = React.useState(false); + const containerRef = useRef(null); + const [isResizing, setIsResizing] = useState(false); + + useEffect(() => { + if (!containerRef.current) { + return; + } + + const currentEl = containerRef.current as HTMLElement; + const wrapperEl = currentEl.closest('.bn-file-block-content-wrapper'); + if (!wrapperEl) { + return; + } + + 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 ? ( +
+