Compare commits
2 commits
5e0f5cf49e
...
731d7f5e93
| Author | SHA1 | Date | |
|---|---|---|---|
| 731d7f5e93 | |||
| 7a11ff4e85 |
3 changed files with 112 additions and 1 deletions
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
|
||||||
|
import type { BridgeRow } from "../types/database-view.types";
|
||||||
|
import { VIEW_DATA_QUERY_KEY } from "./use-view-data";
|
||||||
|
|
||||||
|
interface UseCreateRowOptions {
|
||||||
|
tableId: string;
|
||||||
|
viewId: string;
|
||||||
|
bridgeUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new row in the table via the bridge, then invalidate the view
|
||||||
|
* cache so the row appears.
|
||||||
|
*
|
||||||
|
* Server-first (no optimistic insert): the bridge assigns the row id, and the
|
||||||
|
* inline editor PATCHes by id afterwards — fabricating a temporary id here
|
||||||
|
* would desync the first edit. An empty payload creates a blank row (Baserow
|
||||||
|
* fills defaults); callers may pass initial field values.
|
||||||
|
*/
|
||||||
|
export function useCreateRow({ tableId, viewId, bridgeUrl }: UseCreateRowOptions) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const url = resolveBridgeUrl(bridgeUrl);
|
||||||
|
|
||||||
|
return useMutation<BridgeRow, Error, Record<string, unknown> | void>({
|
||||||
|
mutationFn: async (fields) => {
|
||||||
|
const client = getBridgeClient(url);
|
||||||
|
return (await (client.post(
|
||||||
|
`/api/v1/tables/${tableId}/rows`,
|
||||||
|
fields ?? {},
|
||||||
|
) as unknown)) as BridgeRow;
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
// Reconcile with the server. The bridge also emits an SSE row.created
|
||||||
|
// event which triggers the same invalidation — idempotent.
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import { modals } from "@mantine/modals";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useViewData } from "../hooks/use-view-data";
|
import { useViewData } from "../hooks/use-view-data";
|
||||||
import { useUpdateRow } from "../hooks/use-update-row";
|
import { useUpdateRow } from "../hooks/use-update-row";
|
||||||
|
import { useCreateRow } from "../hooks/use-create-row";
|
||||||
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
|
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
|
||||||
import { usePermissions } from "../hooks/use-permissions";
|
import { usePermissions } from "../hooks/use-permissions";
|
||||||
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
|
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
|
||||||
|
|
@ -186,6 +187,7 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
|
||||||
const canAdminTables = acadenicePerms.hasPermission("tables:write");
|
const canAdminTables = acadenicePerms.hasPermission("tables:write");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
|
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
|
||||||
|
const createRow = useCreateRow({ tableId, viewId, bridgeUrl });
|
||||||
|
|
||||||
// Field admin modal state.
|
// Field admin modal state.
|
||||||
const [fieldModalOpen, setFieldModalOpen] = useState(false);
|
const [fieldModalOpen, setFieldModalOpen] = useState(false);
|
||||||
|
|
@ -352,6 +354,19 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canWriteRows && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
onClick={() => createRow.mutate()}
|
||||||
|
loading={createRow.isPending}
|
||||||
|
mt="xs"
|
||||||
|
>
|
||||||
|
{t("database_view.table.add_row", "Ajouter une ligne")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination — only shown when there is more than one page. */}
|
{/* Pagination — only shown when there is more than one page. */}
|
||||||
{(page > 1 || hasNextPage) && (
|
{(page > 1 || hasNextPage) && (
|
||||||
<div className={styles.pagination}>
|
<div className={styles.pagination}>
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,17 @@ import {
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
type MessageEvent,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Sse,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { fromEvent, interval, merge, type Observable } from 'rxjs';
|
||||||
|
import { filter, map } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
|
|
@ -24,6 +29,10 @@ import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
import { SyncBlocksService } from '../services/sync-blocks.service';
|
import { SyncBlocksService } from '../services/sync-blocks.service';
|
||||||
|
import {
|
||||||
|
SYNC_BLOCK_UPDATED_EVENT,
|
||||||
|
type SyncBlockUpdatedPayload,
|
||||||
|
} from '../services/sync-block-broadcast.service';
|
||||||
import {
|
import {
|
||||||
CreateSyncBlockDto,
|
CreateSyncBlockDto,
|
||||||
UpdateSyncBlockDto,
|
UpdateSyncBlockDto,
|
||||||
|
|
@ -50,7 +59,51 @@ import {
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('v1/sync-blocks')
|
@Controller('v1/sync-blocks')
|
||||||
export class SyncBlocksController {
|
export class SyncBlocksController {
|
||||||
constructor(private readonly syncBlocksService: SyncBlocksService) {}
|
constructor(
|
||||||
|
private readonly syncBlocksService: SyncBlocksService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE stream of updates for a single sync block.
|
||||||
|
*
|
||||||
|
* The client (use-sync-block-realtime) opens an EventSource here and
|
||||||
|
* invalidates its cache on `sync-block.updated`. Auth is the controller's
|
||||||
|
* JwtAuthGuard — EventSource cannot set headers but the JWT strategy reads
|
||||||
|
* the `authToken` cookie, sent automatically same-origin.
|
||||||
|
*
|
||||||
|
* A 25s heartbeat keeps the connection alive through proxies that drop idle
|
||||||
|
* streams (~30s). The client ignores the unknown `ping` event.
|
||||||
|
*/
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Sync block realtime stream (SSE)',
|
||||||
|
description:
|
||||||
|
'Server-sent events: emits `sync-block.updated` when this block changes.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Sync block UUID', type: 'string' })
|
||||||
|
@Sse(':id/events')
|
||||||
|
events(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Observable<MessageEvent> {
|
||||||
|
const updates = fromEvent<SyncBlockUpdatedPayload>(
|
||||||
|
this.eventEmitter,
|
||||||
|
SYNC_BLOCK_UPDATED_EVENT,
|
||||||
|
).pipe(
|
||||||
|
filter((payload) => payload?.masterId === id),
|
||||||
|
map(
|
||||||
|
(payload): MessageEvent => ({
|
||||||
|
type: 'sync-block.updated',
|
||||||
|
data: { masterId: payload.masterId },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const heartbeat = interval(25_000).pipe(
|
||||||
|
map((): MessageEvent => ({ type: 'ping', data: {} })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return merge(updates, heartbeat);
|
||||||
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Create sync block', description: 'Creates a master sync block that can be embedded by reference in multiple pages.' })
|
@ApiOperation({ summary: 'Create sync block', description: 'Creates a master sync block that can be embedded by reference in multiple pages.' })
|
||||||
@ApiBody({ schema: { type: 'object', properties: { content: { type: 'object', description: 'ProseMirror JSON content' } } }, description: 'Initial content (optional)' })
|
@ApiBody({ schema: { type: 'object', properties: { content: { type: 'object', description: 'ProseMirror JSON content' } } }, description: 'Initial content (optional)' })
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue