• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 10299037390

08 Aug 2024 08:55AM UTC coverage: 17.548% (-0.2%) from 17.728%
10299037390

push

github

web-flow
feat: record history (#793)

* feat: record history

* feat: table record history

* feat: the permission for record history

* perf: batch inventory record history

* fix: field constraints are lost during field conversion

* chore: update pnpm-lock file

* fix: the event merging error when updating link records

* fix: cell link UI rendering

* fix: repeated list rendering in formula editor filtering

* chore: db migration for record history

* chore: e2e testing for the record history

* chore: upgrade the dependencies of the Prisma

* chore: upgrade the dependencies of the Prisma

* chore: upgrade the dependencies of the Prisma

* chore: update the dependencies of the Prisma

* fix: date string convert

* chore: update record history E2E testing

* feat: record history supports filtering based on time

* chore: add date field value log

* fix: nested transactions

* chore: revert to the previous version of Prisma

* fix: import table error

* fix: delete table logic

* chore: update record history e2e testing

---------

Co-authored-by: tea artist <artist@teable.io>

1387 of 2823 branches covered (49.13%)

6 of 1035 new or added lines in 43 files covered. (0.58%)

4 existing lines in 4 files now uncovered.

14088 of 80283 relevant lines covered (17.55%)

1.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/packages/sdk/src/components/expand-record/RecordHistory.tsx
NEW
1
import type { QueryFunctionContext } from '@tanstack/react-query';
×
NEW
2
import { useInfiniteQuery } from '@tanstack/react-query';
×
NEW
3
import type { ColumnDef } from '@tanstack/react-table';
×
NEW
4
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
×
NEW
5
import { ArrowRight, ChevronRight } from '@teable/icons';
×
NEW
6
import type { IRecordHistoryItemVo, IRecordHistoryVo } from '@teable/openapi';
×
NEW
7
import { getRecordHistory } from '@teable/openapi';
×
NEW
8
import {
×
NEW
9
  Table,
×
NEW
10
  TableHeader,
×
NEW
11
  TableRow,
×
NEW
12
  TableHead,
×
NEW
13
  TableBody,
×
NEW
14
  TableCell,
×
NEW
15
  Button,
×
NEW
16
} from '@teable/ui-lib';
×
NEW
17
import dayjs from 'dayjs';
×
NEW
18
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
×
NEW
19
import { ReactQueryKeys } from '../../config';
×
NEW
20
import { useTranslation } from '../../context/app/i18n';
×
NEW
21
import { useFieldStaticGetter, useIsHydrated, useTableId } from '../../hooks';
×
NEW
22
import type { IFieldInstance } from '../../model';
×
NEW
23
import { CellValue } from '../cell-value';
×
NEW
24
import { OverflowTooltip } from '../cell-value/components';
×
NEW
25
import { CollaboratorWithHoverCard } from '../collaborator';
×
NEW
26

×
NEW
27
interface IRecordHistoryProps {
×
NEW
28
  recordId?: string;
×
NEW
29
  onRecordClick?: (recordId: string) => void;
×
NEW
30
}
×
NEW
31

×
NEW
32
export const RecordHistory = (props: IRecordHistoryProps) => {
×
NEW
33
  const { recordId, onRecordClick } = props;
×
NEW
34
  const tableId = useTableId() as string;
×
NEW
35
  const { t } = useTranslation();
×
NEW
36
  const isHydrated = useIsHydrated();
×
NEW
37
  const getFieldStatic = useFieldStaticGetter();
×
NEW
38

×
NEW
39
  const listRef = useRef<HTMLDivElement>(null);
×
NEW
40
  const [nextCursor, setNextCursor] = useState<string | null | undefined>();
×
NEW
41
  const [userMap, setUserMap] = useState<IRecordHistoryVo['userMap']>({});
×
NEW
42

×
NEW
43
  const queryFn = async ({ queryKey, pageParam }: QueryFunctionContext) => {
×
NEW
44
    const res = await getRecordHistory(queryKey[1] as string, {
×
NEW
45
      recordId: queryKey[2] as string | undefined,
×
NEW
46
      cursor: pageParam,
×
NEW
47
    });
×
NEW
48
    setNextCursor(() => res.data.nextCursor);
×
NEW
49
    setUserMap({ ...userMap, ...res.data.userMap });
×
NEW
50
    return res.data.historyList;
×
NEW
51
  };
×
NEW
52

×
NEW
53
  const { data, isFetching, isLoading, fetchNextPage } = useInfiniteQuery({
×
NEW
54
    queryKey: ReactQueryKeys.getRecordHistory(tableId, recordId),
×
NEW
55
    queryFn,
×
NEW
56
    refetchOnMount: 'always',
×
NEW
57
    refetchOnWindowFocus: false,
×
NEW
58
    getNextPageParam: () => nextCursor,
×
NEW
59
  });
×
NEW
60

×
NEW
61
  const allRows = useMemo(() => (data ? data.pages.flatMap((d) => d) : []), [data]);
×
NEW
62

×
NEW
63
  const fetchMoreOnBottomReached = useCallback(
×
NEW
64
    (containerRefElement?: HTMLDivElement | null) => {
×
NEW
65
      if (containerRefElement) {
×
NEW
66
        const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
×
NEW
67
        const isReachedThreshold = scrollHeight - scrollTop - clientHeight < 30;
×
NEW
68
        if (!isFetching && nextCursor && isReachedThreshold) {
×
NEW
69
          fetchNextPage();
×
NEW
70
        }
×
NEW
71
      }
×
NEW
72
    },
×
NEW
73
    [fetchNextPage, isFetching, nextCursor]
×
NEW
74
  );
×
NEW
75

×
NEW
76
  useEffect(() => {
×
NEW
77
    fetchMoreOnBottomReached(listRef.current);
×
NEW
78
  }, [fetchMoreOnBottomReached]);
×
NEW
79

×
NEW
80
  const columns: ColumnDef<IRecordHistoryItemVo>[] = useMemo(() => {
×
NEW
81
    const actionVisible = !recordId && onRecordClick;
×
NEW
82
    const tableColumns: ColumnDef<IRecordHistoryItemVo>[] = [
×
NEW
83
      {
×
NEW
84
        accessorKey: 'createdTime',
×
NEW
85
        header: t('expandRecord.recordHistory.createdTime'),
×
NEW
86
        size: 80,
×
NEW
87
        cell: ({ row }) => {
×
NEW
88
          const createdTime = row.getValue<string>('createdTime');
×
NEW
89
          const createdDate = dayjs(createdTime);
×
NEW
90
          const isToday = createdDate.isSame(dayjs(), 'day');
×
NEW
91
          return (
×
NEW
92
            <div className="text-xs" title={createdDate.format('YYYY/MM/DD HH:mm')}>
×
NEW
93
              {createdDate.format(isToday ? 'HH:mm' : 'YYYY/MM/DD')}
×
NEW
94
            </div>
×
NEW
95
          );
×
NEW
96
        },
×
NEW
97
      },
×
NEW
98
      {
×
NEW
99
        accessorKey: 'createdBy',
×
NEW
100
        header: t('expandRecord.recordHistory.createdBy'),
×
NEW
101
        size: 60,
×
NEW
102
        cell: ({ row }) => {
×
NEW
103
          const createdBy = row.getValue<string>('createdBy');
×
NEW
104
          const user = userMap[createdBy];
×
NEW
105

×
NEW
106
          if (!user) return null;
×
NEW
107

×
NEW
108
          const { id, name, avatar, email } = user;
×
NEW
109

×
NEW
110
          return (
×
NEW
111
            <div className="flex justify-center">
×
NEW
112
              <CollaboratorWithHoverCard id={id} name={name} avatar={avatar} email={email} />
×
NEW
113
            </div>
×
NEW
114
          );
×
NEW
115
        },
×
NEW
116
      },
×
NEW
117
      {
×
NEW
118
        accessorKey: 'field',
×
NEW
119
        header: t('noun.field'),
×
NEW
120
        size: 128,
×
NEW
121
        cell: ({ row }) => {
×
NEW
122
          const after = row.getValue<IRecordHistoryItemVo['after']>('after');
×
NEW
123
          const { name: fieldName, type: fieldType } = after.meta;
×
NEW
124
          const { Icon } = getFieldStatic(fieldType, false);
×
NEW
125
          return (
×
NEW
126
            <div className="flex items-center gap-x-1">
×
NEW
127
              <Icon className="shrink-0" />
×
NEW
128
              <OverflowTooltip text={fieldName} maxLine={1} className="flex-1 text-[13px]" />
×
NEW
129
            </div>
×
NEW
130
          );
×
NEW
131
        },
×
NEW
132
      },
×
NEW
133
      {
×
NEW
134
        accessorKey: 'before',
×
NEW
135
        header: t('expandRecord.recordHistory.before'),
×
NEW
136
        size: actionVisible ? 220 : 280,
×
NEW
137
        cell: ({ row }) => {
×
NEW
138
          const before = row.getValue<IRecordHistoryItemVo['before']>('before');
×
NEW
139
          return (
×
NEW
140
            <Fragment>
×
NEW
141
              {before.data != null ? (
×
NEW
142
                <CellValue
×
NEW
143
                  value={before.data}
×
NEW
144
                  field={before.meta as IFieldInstance}
×
NEW
145
                  maxLine={4}
×
NEW
146
                  className={actionVisible ? 'max-w-52' : 'max-w-[264px]'}
×
NEW
147
                />
×
NEW
148
              ) : (
×
NEW
149
                <span className="text-gray-500">{t('common.empty')}</span>
×
NEW
150
              )}
×
NEW
151
            </Fragment>
×
NEW
152
          );
×
NEW
153
        },
×
NEW
154
      },
×
NEW
155
      {
×
NEW
156
        accessorKey: 'arrow',
×
NEW
157
        header: '',
×
NEW
158
        size: 40,
×
NEW
159
        cell: () => {
×
NEW
160
          return (
×
NEW
161
            <div className="flex w-full justify-center">
×
NEW
162
              <ArrowRight className="text-gray-500" />
×
NEW
163
            </div>
×
NEW
164
          );
×
NEW
165
        },
×
NEW
166
      },
×
NEW
167
      {
×
NEW
168
        accessorKey: 'after',
×
NEW
169
        header: t('expandRecord.recordHistory.after'),
×
NEW
170
        size: actionVisible ? 220 : 280,
×
NEW
171
        cell: ({ row }) => {
×
NEW
172
          const after = row.getValue<IRecordHistoryItemVo['after']>('after');
×
NEW
173
          return (
×
NEW
174
            <Fragment>
×
NEW
175
              {after.data != null ? (
×
NEW
176
                <CellValue
×
NEW
177
                  value={after.data}
×
NEW
178
                  field={after.meta as IFieldInstance}
×
NEW
179
                  maxLine={4}
×
NEW
180
                  className={actionVisible ? 'max-w-52' : 'max-w-[264px]'}
×
NEW
181
                />
×
NEW
182
              ) : (
×
NEW
183
                <span className="text-gray-500">{t('common.empty')}</span>
×
NEW
184
              )}
×
NEW
185
            </Fragment>
×
NEW
186
          );
×
NEW
187
        },
×
NEW
188
      },
×
NEW
189
    ];
×
NEW
190

×
NEW
191
    if (actionVisible) {
×
NEW
192
      tableColumns.push({
×
NEW
193
        accessorKey: 'recordId',
×
NEW
194
        header: t('common.actions'),
×
NEW
195
        size: 120,
×
NEW
196
        cell: ({ row }) => {
×
NEW
197
          const recordId = row.getValue<string>('recordId');
×
NEW
198
          return (
×
NEW
199
            <Button
×
NEW
200
              size="xs"
×
NEW
201
              variant="secondary"
×
NEW
202
              className="h-6 gap-1 font-normal"
×
NEW
203
              onClick={() => onRecordClick(recordId)}
×
NEW
204
            >
×
NEW
205
              {t('expandRecord.recordHistory.viewRecord')}
×
NEW
206
              <ChevronRight className="size-4" />
×
NEW
207
            </Button>
×
NEW
208
          );
×
NEW
209
        },
×
NEW
210
      });
×
NEW
211
    }
×
NEW
212

×
NEW
213
    return tableColumns;
×
NEW
214
  }, [recordId, userMap, t, getFieldStatic, onRecordClick]);
×
NEW
215

×
NEW
216
  const table = useReactTable({
×
NEW
217
    data: allRows,
×
NEW
218
    columns,
×
NEW
219
    getCoreRowModel: getCoreRowModel(),
×
NEW
220
  });
×
NEW
221

×
NEW
222
  if (!isHydrated || isLoading) return null;
×
NEW
223

×
NEW
224
  return (
×
NEW
225
    <div
×
NEW
226
      ref={listRef}
×
NEW
227
      className="relative size-full overflow-auto px-2 sm:overflow-x-hidden"
×
NEW
228
      onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
×
NEW
229
    >
×
NEW
230
      <Table className="relative scroll-smooth">
×
NEW
231
        <TableHeader className="sticky top-0 z-10 bg-background">
×
NEW
232
          {table.getHeaderGroups().map((headerGroup) => (
×
NEW
233
            <TableRow
×
NEW
234
              key={headerGroup.id}
×
NEW
235
              className="flex h-10 bg-background text-[13px] hover:bg-background"
×
NEW
236
            >
×
NEW
237
              {headerGroup.headers.map((header) => {
×
NEW
238
                return (
×
NEW
239
                  <TableHead
×
NEW
240
                    key={header.id}
×
NEW
241
                    className="flex items-center"
×
NEW
242
                    style={{
×
NEW
243
                      width: header.getSize(),
×
NEW
244
                    }}
×
NEW
245
                  >
×
NEW
246
                    {flexRender(header.column.columnDef.header, header.getContext())}
×
NEW
247
                  </TableHead>
×
NEW
248
                );
×
NEW
249
              })}
×
NEW
250
            </TableRow>
×
NEW
251
          ))}
×
NEW
252
        </TableHeader>
×
NEW
253
        <TableBody>
×
NEW
254
          {table.getRowModel().rows?.length ? (
×
NEW
255
            table.getRowModel().rows.map((row) => (
×
NEW
256
              <TableRow key={row.id} className="flex text-[13px]">
×
NEW
257
                {row.getVisibleCells().map((cell) => (
×
NEW
258
                  <TableCell
×
NEW
259
                    key={cell.id}
×
NEW
260
                    className="flex min-h-[40px] items-center"
×
NEW
261
                    style={{
×
NEW
262
                      width: cell.column.getSize(),
×
NEW
263
                    }}
×
NEW
264
                  >
×
NEW
265
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
×
NEW
266
                  </TableCell>
×
NEW
267
                ))}
×
NEW
268
              </TableRow>
×
NEW
269
            ))
×
NEW
270
          ) : (
×
NEW
271
            <TableRow>
×
NEW
272
              <TableCell colSpan={columns.length} className="h-24 text-center">
×
NEW
273
                {t('common.empty')}
×
NEW
274
              </TableCell>
×
NEW
275
            </TableRow>
×
NEW
276
          )}
×
NEW
277
        </TableBody>
×
NEW
278
      </Table>
×
NEW
279
    </div>
×
NEW
280
  );
×
NEW
281
};
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc