Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dataset preview data #143

Merged
merged 1 commit into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ const nextConfig = {
// on the name of job handler functions for things to work properly. As you
// can imagine, minification would break this. So we have to disable it.
serverMinification: false,
// TODO: Review this decision. It would be more performant to use
// direct uploads. To implement it we need to generate a signed URL
// that's send directly to S3 and the clint upload the file to Amazon directly
// What I stopped me from implementing is that dev experience would (maybe) be
// different because in local environment we would need to upload the file to
// the nextjs server as we do now.
serverActions: {
bodySizeLimit: '15mb',
},
},
}

Expand Down
75 changes: 61 additions & 14 deletions apps/web/src/actions/datasets/create.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,88 @@
'use server'

import { DatasetsRepository } from '@latitude-data/core/repositories'
import { createDataset } from '@latitude-data/core/services/datasets/create'
import disk from '$/lib/disk'
import { z } from 'zod'

import { authProcedure } from '../procedures'

const DELIMITERS_KEYS = [
'comma',
'semicolon',
'tab',
'space',
'custom',
] as const
const DELIMITER_VALUES = {
comma: ',',
semicolon: ';',
tab: '\t',
space: ' ',
}

const MAX_SIZE = 3
const MAX_UPLOAD_SIZE_IN_MB = 3 * 1024 * 1024
Comment on lines 24 to 25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking but use MAX_SIZE to calculate MAX_SIZE_IN_MB

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No good catch 💯 . This needs to be changed to 15MB now. I forgot thanks


export const createDatasetAction = authProcedure
.createServerAction()
.input(
z.object({
name: z.string().min(1, { message: 'Name is required' }),
dataset_file: z
.instanceof(File)
.refine((file) => {
return !file || file.size <= MAX_UPLOAD_SIZE_IN_MB
}, `Your dataset must be less than ${MAX_SIZE}MB in size`)
async ({ ctx }) => {
return z
.object({
name: z
.string()
.min(1, { message: 'Name is required' })
.refine(
async (name) => {
const scope = new DatasetsRepository(ctx.workspace.id)
const existing = await scope.findByName(name)
return !existing
},
{
message:
'This name was already used, plese use something different',
},
),
csvDelimiter: z.enum(DELIMITERS_KEYS, {
message: 'Choose a valid delimiter option',
}),
csvCustomDelimiter: z.string(),
Comment on lines +46 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't csvCustomDelimiter be optional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's ok, we always send an empty string because the field is always sent. Is in the validation that we check user picked custom and this field has content

dataset_file: z
.instanceof(File)
.refine((file) => {
return !file || file.size <= MAX_UPLOAD_SIZE_IN_MB
}, `Your dataset must be less than ${MAX_SIZE}MB in size`)
.refine(
(file) => file.type === 'text/csv',
'Your dataset must be a CSV file',
),
})
.refine(
(file) => file.type === 'text/csv',
'Your dataset must be a CSV file',
),
}),
(schema) => {
if (schema.csvDelimiter !== 'custom') return true
return schema.csvCustomDelimiter.length > 0
},
{
message: 'Custom delimiter is required',
path: ['csvCustomDelimiter'],
},
)
},
{ type: 'formData' },
)
.handler(async ({ input, ctx }) => {
const csvDelimiter =
input.csvDelimiter === 'custom'
? input.csvCustomDelimiter
: DELIMITER_VALUES[input.csvDelimiter]
return createDataset({
workspace: ctx.workspace,
author: ctx.user,
disk: disk,
data: {
name: input.name,
file: input.dataset_file,
// TODO: Make UI radio button to pick delimiter
csvDelimiter: ';',
csvDelimiter,
},
}).then((r) => r.unwrap())
})
21 changes: 21 additions & 0 deletions apps/web/src/actions/datasets/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use server'

import { DatasetsRepository } from '@latitude-data/core/repositories'
import { previewDataset } from '@latitude-data/core/services/datasets/preview'
import disk from '$/lib/disk'
import { z } from 'zod'

import { authProcedure } from '../procedures'

export const previewDatasetAction = authProcedure
.createServerAction()
.input(
z.object({
id: z.number(),
}),
)
.handler(async ({ ctx, input }) => {
const repo = new DatasetsRepository(ctx.workspace.id)
const dataset = await repo.find(input.id).then((r) => r.unwrap())
return await previewDataset({ dataset, disk }).then((r) => r.unwrap())
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
TableHeader,
TableRow,
Text,
Tooltip,
} from '@latitude-data/web-ui'
import DeleteDatasetModal from '$/app/(private)/datasets/_components/DeleteDatasetModal'
import PreviewDatasetModal from '$/app/(private)/datasets/_components/PreviewDatasetModal'
import useDatasets from '$/stores/datasets'

export function DatasetsTable({
Expand All @@ -23,12 +25,14 @@ export function DatasetsTable({
datasets: Dataset[]
}) {
const [deletable, setDeletable] = useState<Dataset | null>(null)
const [preview, setPreview] = useState<Dataset | null>(null)
const { data: datasets } = useDatasets(undefined, {
fallbackData: serverDatasets,
})
return (
<>
<DeleteDatasetModal dataset={deletable} setDataset={setDeletable} />
<PreviewDatasetModal dataset={preview} setPreview={setPreview} />
<Table>
<TableHeader>
<TableRow verticalPadding>
Expand All @@ -38,6 +42,7 @@ export function DatasetsTable({
<TableHead>Author</TableHead>
<TableHead>Created at</TableHead>
<TableHead />
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -60,6 +65,21 @@ export function DatasetsTable({
{dateFormatter.formatDate(dataset.createdAt)}
</Text.H4>
</TableCell>
<TableCell align='center'>
<Tooltip
trigger={
<Button
onClick={() => setPreview(dataset)}
variant='nope'
iconProps={{ name: 'eye', color: 'foregroundMuted' }}
/>
}
>
<Text.H6B color='white'>
Show file preview (first 100 rows)
</Text.H6B>
</Tooltip>
</TableCell>
<TableCell align='center'>
<Button
onClick={() => setDeletable(dataset)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Dataset } from '@latitude-data/core/browser'

import '@latitude-data/web-ui'

import {
Button,
Modal,
ModalTrigger,
ReactStateDispatch,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableSkeleton,
Text,
} from '@latitude-data/web-ui'
import useDatasetPreview from '$/stores/datasetPreviews'

const VISIBLE_ROWS = 20
function PreviewModal({
dataset,
setPreview,
}: {
dataset: Dataset
setPreview: ReactStateDispatch<Dataset | null>
}) {
const { data, isLoading } = useDatasetPreview({ dataset })
const rows = data?.rows ?? []
const rowCount = Math.min(dataset.fileMetadata.rowCount, VISIBLE_ROWS)
return (
<Modal
size='large'
open
title={`${dataset.name} preview`}
description='First 100 rows of the dataset'
onOpenChange={(open: boolean) => !open && setPreview(null)}
footer={
<ModalTrigger asChild>
<Button fancy variant='outline'>
Go back
</Button>
</ModalTrigger>
}
>
{isLoading ? (
<TableSkeleton
rows={rowCount}
cols={dataset.fileMetadata.headers.length}
/>
) : (
<Table maxHeight={450}>
<TableHeader>
<TableRow verticalPadding>
<TableHead>
<Text.H4>#</Text.H4>
</TableHead>
{data.headers.map((header, i) => (
<TableHead key={`${header}-${i}`}>
<Text.H4>{header}</Text.H4>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, rowIndex) => {
return (
<TableRow key={rowIndex} verticalPadding>
{row.map((cell, cellIndex) => (
<TableCell key={cellIndex}>{cell}</TableCell>
))}
</TableRow>
)
})}
</TableBody>
</Table>
)}
</Modal>
)
}

export default function PreviewDatasetModal({
dataset,
setPreview,
}: {
dataset: Dataset | null
setPreview: ReactStateDispatch<Dataset | null>
}) {
if (!dataset) return null

return <PreviewModal dataset={dataset} setPreview={setPreview} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client'

import { useCallback, useRef, useState } from 'react'

import { cn, FormFieldGroup, Input, Select } from '@latitude-data/web-ui'

export enum DelimiterEnum {
Comma = 'comma',
Semicolon = 'semicolon',
Tab = 'tab',
Space = 'space',
Custom = 'custom',
}

const DELIMITERS = [
{ value: DelimiterEnum.Comma, label: 'Comma (ex.: column1,column2,column3)' },
{
value: DelimiterEnum.Semicolon,
label: 'Semicolon (ex.: column1;column2;column3)',
},
{
value: DelimiterEnum.Tab,
label: 'Tab (ex.: column1 \\t column2 \\t column3)',
},
{ value: DelimiterEnum.Space, label: 'Space (ex.: column1 column2 column3)' },
{ value: DelimiterEnum.Custom, label: 'Custom' },
]
export const DELIMITER_KEYS = DELIMITERS.map(({ value }) => value)

export default function DelimiterSelector({
delimiterInputName,
customDelimiterInputName,
delimiterErrors,
customDelimiterErrors,
delimiterValue,
customDelimiterValue,
}: {
delimiterInputName: string
delimiterValue: string | undefined
delimiterErrors: string[] | undefined
customDelimiterInputName: string
customDelimiterValue: string
customDelimiterErrors: string[] | undefined
}) {
const inputRef = useRef<HTMLInputElement>(null)
const [isCustom, setIsCustom] = useState(false)
const onSelectChange = useCallback((value: string) => {
const custom = value === 'custom'
setIsCustom(custom)
setTimeout(() => {
if (!custom) return
inputRef.current?.focus()
}, 0)
}, [])
return (
<FormFieldGroup
label='CSV header delimiter'
description='Specify the delimiter used in the first line of the uploaded .csv file.'
>
<Select
name={delimiterInputName}
options={DELIMITERS}
defaultValue={delimiterValue}
onChange={onSelectChange}
errors={delimiterErrors}
/>
<Input
ref={inputRef}
name={customDelimiterInputName}
className={cn({ hidden: !isCustom })}
defaultValue={customDelimiterValue}
errors={customDelimiterErrors}
placeholder='Your custom delimiter'
/>
</FormFieldGroup>
)
}
Loading
Loading