design improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-28 10:19:37 +01:00
parent ee2ccbaa98
commit 3679caf547
22 changed files with 581 additions and 349 deletions

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox, CheckboxInput } from '@/components/ui/checkbox';
import { CheckboxInput } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,

View File

@@ -18,30 +18,31 @@ import { EventIcon } from './event-icon';
type EventListItemProps = IServiceCreateEventPayload;
export function EventListItem({
profile,
createdAt,
name,
properties,
path,
duration,
referrer,
referrerName,
referrerType,
brand,
model,
browser,
browserVersion,
os,
osVersion,
city,
region,
country,
continent,
device,
projectId,
meta,
}: EventListItemProps) {
export function EventListItem(props: EventListItemProps) {
const {
profile,
createdAt,
name,
properties,
path,
duration,
referrer,
referrerName,
referrerType,
brand,
model,
browser,
browserVersion,
os,
osVersion,
city,
region,
country,
continent,
device,
projectId,
meta,
} = props;
const params = useAppParams();
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
const [, setFilter] = useEventQueryFilters({ shallow: false });
@@ -168,6 +169,9 @@ export function EventListItem({
content={
<>
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
{profile?.id === props.deviceId && (
<KeyValueSubtle name="Anonymous" value={'Yes'} />
)}
{profile && (
<KeyValueSubtle
name="Profile"

View File

@@ -62,9 +62,7 @@ export default async function Page({
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<div className="col-span-6">
<OverviewTopGeo projectId={projectId} />
</div>
<OverviewTopGeo projectId={projectId} />
</div>
</PageLayout>
);

View File

@@ -1,61 +0,0 @@
'use client';
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/Pagination';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { useEventNames } from '@/hooks/useEventNames';
import { parseAsJson, useQueryState } from 'nuqs';
import { EventListItem } from '../../events/event-list-item';
interface ListProfileEvents {
projectId: string;
profileId: string;
}
export default function ListProfileEvents({
projectId,
profileId,
}: ListProfileEvents) {
const pagination = usePagination(50);
const [eventFilters, setEventFilters] = useQueryState(
'events',
parseAsJson<string[]>().withDefault([])
);
const eventNames = useEventNames(projectId);
const eventsQuery = api.event.list.useQuery(
{
projectId,
profileId,
events: eventFilters,
...pagination,
},
{
keepPreviousData: true,
}
);
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
return (
<>
<div className="flex items-center justify-between mb-4">
<ComboboxAdvanced
placeholder="Filter events"
items={eventNames}
value={eventFilters}
onChange={setEventFilters}
/>
</div>
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation';
import { parseAsInteger, parseAsString } from 'nuqs';

View File

@@ -7,7 +7,7 @@ import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-fi
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopEvents from '@/components/overview/overview-top-events/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';

View File

@@ -3,6 +3,7 @@ import { cn } from '@/utils/cn';
import Providers from './providers';
import '@/styles/globals.css';
import '/node_modules/flag-icons/css/flag-icons.min.css';
export const metadata = {
title: 'Overview - Openpanel.dev',

View File

@@ -191,28 +191,31 @@ export default function OverviewTopDevices({
<WidgetBody>
<ChartSwitch
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'devices':
setFilter('device', item.name);
break;
case 'browser':
setWidget('browser_version');
setFilter('browser', item.name);
break;
case 'browser_version':
setFilter('browser_version', item.name);
break;
case 'os':
setWidget('os_version');
setFilter('os', item.name);
break;
case 'os_version':
setFilter('os_version', item.name);
break;
}
{...{
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser_version',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>

View File

@@ -0,0 +1,16 @@
import { getConversionEventNames } from '@mixan/db';
import type { OverviewTopEventsProps } from './overview-top-events';
import OverviewTopEvents from './overview-top-events';
export default async function OverviewTopEventsServer({
projectId,
}: Omit<OverviewTopEventsProps, 'conversions'>) {
const eventNames = await getConversionEventNames(projectId);
return (
<OverviewTopEvents
projectId={projectId}
conversions={eventNames.map((item) => item.name)}
/>
);
}

View File

@@ -4,16 +4,18 @@ import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
import { Widget, WidgetBody } from '../../Widget';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
interface OverviewTopEventsProps {
export interface OverviewTopEventsProps {
projectId: string;
conversions: string[];
}
export default function OverviewTopEvents({
projectId,
conversions,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
@@ -57,6 +59,44 @@ export default function OverviewTopEvents({
metric: 'sum',
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions,
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (

View File

@@ -17,36 +17,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
map: {
title: 'Map',
btn: 'Map',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
countries: {
title: 'Top countries',
btn: 'Countries',
@@ -179,6 +149,42 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
/>
</WidgetBody>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ChartSwitch
hideID
{...{
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -11,7 +11,6 @@ import type { IChartMetric } from '@mixan/validation';
import {
getDiffIndicator,
PreviousDiffIndicator,
PreviousDiffIndicatorText,
} from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';

View File

@@ -9,7 +9,7 @@ import { getChartColor } from '@/utils/theme';
import { NOT_SET_VALUE } from '@mixan/constants';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
@@ -30,42 +30,41 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
<div
className={cn(
'flex flex-col w-full text-xs -mx-2',
editMode &&
'text-base bg-white border border-border rounded-md p-4 pt-2'
editMode && 'text-base bg-white border border-border rounded-md p-4'
)}
>
{editMode && (
<div className="-m-4 -mb-px flex justify-between font-medium p-4 pt-5 border-b border-border font-medium text-muted-foreground">
<div>Event</div>
<div>Count</div>
</div>
)}
{series.map((serie, index) => {
{series.map((serie) => {
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
return (
<div
key={serie.name}
className={cn(
'py-2 px-2 flex flex-1 w-full gap-4 items-center even:bg-slate-50 [&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm rounded',
'relative py-3 px-2 flex flex-1 w-full gap-4 items-center even:bg-slate-50 [&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm rounded overflow-hidden',
isClickable && 'cursor-pointer hover:!bg-slate-100'
)}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
>
<div className="flex-1 break-all flex items-center gap-2">
<div className="flex-1 break-all flex items-center gap-2 font-medium">
<SerieIcon name={serie.name} />
{serie.name}
</div>
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
<PreviousDiffIndicatorText
{...serie.metrics.previous[metric]}
className="text-xs font-medium"
/>
{serie.metrics.previous[metric]?.value}
<div className="font-bold">
{number.format(serie.metrics.sum)}
</div>
<Progress
color={getChartColor(index)}
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
value={(serie.metrics.sum / maxCount) * 100}
/>
</div>
<div
className="absolute left-0 bottom-0 h-0.5 rounded-full min-w-2 z-10 bg-blue-600"
style={{
width: `${(serie.metrics.sum / maxCount) * 100}%`,
}}
></div>
</div>
);
})}

View File

@@ -67,15 +67,12 @@ export function ReportChartTooltip({
{getLabel(data.label)}
</div>
<div className="flex justify-between gap-8">
<div>
{number.format(data.count)}
{unit}
</div>
<div>{number.formatWithUnit(data.count, unit)}</div>
<div className="flex gap-1">
<PreviousDiffIndicator {...data.previous}>
{!!data.previous &&
`(${data.previous.value + (unit ? unit : '')})`}
`(${number.formatWithUnit(data.previous.value, unit)})`}
</PreviousDiffIndicator>
</div>
</div>

View File

@@ -30,6 +30,12 @@ const createImageIcon = (url: string) => {
} as LucideIcon;
};
const createFlagIcon = (url: string) => {
return function (props: LucideProps) {
return <span className={`rounded fi fi-${url}`}></span>;
} as LucideIcon;
};
const mapper: Record<string, LucideIcon> = {
// Events
screen_view: MonitorPlayIcon,
@@ -88,6 +94,121 @@ const mapper: Record<string, LucideIcon> = {
email: MailIcon,
unknown: HelpCircleIcon,
[NOT_SET_VALUE]: ScanIcon,
// Flags
se: createFlagIcon('se'),
us: createFlagIcon('us'),
gb: createFlagIcon('gb'),
ua: createFlagIcon('ua'),
ru: createFlagIcon('ru'),
de: createFlagIcon('de'),
fr: createFlagIcon('fr'),
br: createFlagIcon('br'),
in: createFlagIcon('in'),
it: createFlagIcon('it'),
es: createFlagIcon('es'),
pl: createFlagIcon('pl'),
nl: createFlagIcon('nl'),
id: createFlagIcon('id'),
tr: createFlagIcon('tr'),
ph: createFlagIcon('ph'),
ca: createFlagIcon('ca'),
ar: createFlagIcon('ar'),
mx: createFlagIcon('mx'),
za: createFlagIcon('za'),
au: createFlagIcon('au'),
co: createFlagIcon('co'),
ch: createFlagIcon('ch'),
at: createFlagIcon('at'),
be: createFlagIcon('be'),
pt: createFlagIcon('pt'),
my: createFlagIcon('my'),
th: createFlagIcon('th'),
vn: createFlagIcon('vn'),
sg: createFlagIcon('sg'),
eg: createFlagIcon('eg'),
sa: createFlagIcon('sa'),
pk: createFlagIcon('pk'),
bd: createFlagIcon('bd'),
ro: createFlagIcon('ro'),
hu: createFlagIcon('hu'),
cz: createFlagIcon('cz'),
gr: createFlagIcon('gr'),
il: createFlagIcon('il'),
no: createFlagIcon('no'),
fi: createFlagIcon('fi'),
dk: createFlagIcon('dk'),
sk: createFlagIcon('sk'),
bg: createFlagIcon('bg'),
hr: createFlagIcon('hr'),
rs: createFlagIcon('rs'),
ba: createFlagIcon('ba'),
si: createFlagIcon('si'),
lv: createFlagIcon('lv'),
lt: createFlagIcon('lt'),
ee: createFlagIcon('ee'),
by: createFlagIcon('by'),
md: createFlagIcon('md'),
kz: createFlagIcon('kz'),
uz: createFlagIcon('uz'),
kg: createFlagIcon('kg'),
tj: createFlagIcon('tj'),
tm: createFlagIcon('tm'),
az: createFlagIcon('az'),
ge: createFlagIcon('ge'),
am: createFlagIcon('am'),
af: createFlagIcon('af'),
ir: createFlagIcon('ir'),
iq: createFlagIcon('iq'),
sy: createFlagIcon('sy'),
lb: createFlagIcon('lb'),
jo: createFlagIcon('jo'),
ps: createFlagIcon('ps'),
kw: createFlagIcon('kw'),
qa: createFlagIcon('qa'),
om: createFlagIcon('om'),
ye: createFlagIcon('ye'),
ae: createFlagIcon('ae'),
bh: createFlagIcon('bh'),
cy: createFlagIcon('cy'),
mt: createFlagIcon('mt'),
sm: createFlagIcon('sm'),
li: createFlagIcon('li'),
is: createFlagIcon('is'),
al: createFlagIcon('al'),
mk: createFlagIcon('mk'),
me: createFlagIcon('me'),
ad: createFlagIcon('ad'),
lu: createFlagIcon('lu'),
mc: createFlagIcon('mc'),
fo: createFlagIcon('fo'),
gg: createFlagIcon('gg'),
je: createFlagIcon('je'),
im: createFlagIcon('im'),
gi: createFlagIcon('gi'),
va: createFlagIcon('va'),
ax: createFlagIcon('ax'),
bl: createFlagIcon('bl'),
mf: createFlagIcon('mf'),
pm: createFlagIcon('pm'),
yt: createFlagIcon('yt'),
wf: createFlagIcon('wf'),
tf: createFlagIcon('tf'),
re: createFlagIcon('re'),
sc: createFlagIcon('sc'),
mu: createFlagIcon('mu'),
zw: createFlagIcon('zw'),
mz: createFlagIcon('mz'),
na: createFlagIcon('na'),
bw: createFlagIcon('bw'),
ls: createFlagIcon('ls'),
sz: createFlagIcon('sz'),
bi: createFlagIcon('bi'),
rw: createFlagIcon('rw'),
ug: createFlagIcon('ug'),
ke: createFlagIcon('ke'),
tz: createFlagIcon('tz'),
mg: createFlagIcon('mg'),
};
export function SerieIcon({ name, ...props }: SerieIconProps) {

View File

@@ -30,5 +30,26 @@ export function useNumber() {
return {
format,
short,
shortWithUnit: (value: number | null | undefined, unit?: string | null) => {
if (isNil(value)) {
return 'N/A';
}
if (unit === 'min') {
return fancyMinutes(value);
}
return `${short(value)}${unit ? ` ${unit}` : ''}`;
},
formatWithUnit: (
value: number | null | undefined,
unit?: string | null
) => {
if (isNil(value)) {
return 'N/A';
}
if (unit === 'min') {
return fancyMinutes(value);
}
return `${format(value)}${unit ? ` ${unit}` : ''}`;
},
};
}

View File

@@ -1,17 +1,25 @@
'use client';
import { useEffect } from 'react';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import Syntax from '@/components/Syntax';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { CheckboxInput } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppParams } from '@/hooks/useAppParams';
import { clipboard } from '@/utils/clipboard';
import { zodResolver } from '@hookform/resolvers/zod';
import { Copy } from 'lucide-react';
import { Copy, SaveIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
@@ -19,194 +27,234 @@ import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = z.object({
name: z.string().min(1, 'Required'),
cors: z.string().min(1, 'Required'),
withCors: z.boolean(),
projectId: z.string().min(1, 'Required'),
const validation = z.object({
name: z.string().min(1),
domain: z.string().optional(),
withSecret: z.boolean().optional(),
projectId: z.string(),
});
type IForm = z.infer<typeof validator>;
interface AddClientProps {
organizationId: string;
}
type IForm = z.infer<typeof validation>;
export default function AddClient({ organizationId }: AddClientProps) {
export default function AddClient() {
const { organizationId, projectId } = useAppParams();
const router = useRouter();
const query = api.project.list.useQuery({
organizationId,
const form = useForm<IForm>({
resolver: zodResolver(validation),
defaultValues: {
withSecret: false,
name: '',
domain: '',
projectId,
},
});
const mutation = api.client.create.useMutation({
const mutation = api.client.create2.useMutation({
onError: handleError,
onSuccess() {
toast('Success', {
description: 'Client created!',
});
toast.success('Client created');
router.refresh();
},
});
const { register, handleSubmit, formState, control } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
name: '',
cors: '*',
projectId: '',
withCors: true,
},
const query = api.project.list.useQuery({
organizationId,
});
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
name: values.name,
domain: values.withSecret ? undefined : values.domain,
projectId: values.projectId,
organizationId,
});
};
const withCors = useWatch({
control,
name: 'withCors',
const watch = useWatch({
control: form.control,
name: 'withSecret',
});
if (mutation.isSuccess && mutation.data) {
const { clientId, clientSecret, cors } = mutation.data;
const snippet = clientSecret
? `const mixan = new Mixan({
clientId: "${clientId}",
// Avoid using this on web, rely on cors settings instead
// Mostly for react-native and node/backend
clientSecret: "${clientSecret}",
})`
: `const mixan = new Mixan({
clientId: "${clientId}",
})`;
return (
<ModalContent>
<ModalHeader title="Client created 🚀" />
<p>
Your client has been created! You will only see the client secret once
so keep it safe 🫣
</p>
<button className="mt-4 text-left" onClick={() => clipboard(cors)}>
<Label>Cors settings</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{cors}
<Copy size={16} />
</div>
</button>
<button className="mt-4 text-left" onClick={() => clipboard(clientId)}>
<Label>Client ID</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{clientId}
<Copy size={16} />
</div>
</button>
{clientSecret && (
<button
className="mt-4 text-left"
onClick={() => clipboard(clientSecret)}
>
<Label>Client Secret</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{clientSecret}
<Copy size={16} />
</div>
</button>
)}
<div className="mt-4">
<Label>Code snippet</Label>
<div className="[&_pre]:!rounded [&_pre]:!bg-gray-100 [&_pre]:text-sm">
<Syntax code={snippet} />
</div>
</div>
<ButtonContainer>
<div />
<Button onClick={() => popModal()}>Done</Button>
</ButtonContainer>
</ModalContent>
);
}
return (
<ModalContent>
<ModalHeader title="Create client" />
<form
onSubmit={handleSubmit((values) => {
mutation.mutate({
...values,
organizationId,
});
})}
>
<InputWithLabel
label="Name"
placeholder="Name"
{...register('name')}
className="mb-4"
/>
<Controller
name="withCors"
control={control}
render={({ field }) => (
<label
htmlFor="cors"
className="flex items-center gap-2 text-sm font-medium leading-none mb-4"
{mutation.isSuccess ? (
<>
<ModalHeader
title="Success"
text={
<>
{mutation.data.clientSecret
? 'Use your client id and secret with our SDK to send events to us. '
: 'Use your client id with our SDK to send events to us. '}
See our{' '}
<Link href="https//openpanel.dev/docs" className="underline">
documentation
</Link>
</>
}
/>
<div className="grid gap-4">
<button
className="mt-4 text-left"
onClick={() => clipboard(mutation.data.clientId)}
>
<Checkbox
id="cors"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
<Label>Client ID</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{mutation.data.clientId}
<Copy size={16} />
</div>
</button>
{mutation.data.clientSecret ? (
<button
className="mt-4 text-left"
onClick={() => clipboard(mutation.data.clientId)}
>
<Label>Secret</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{mutation.data.clientSecret}
<Copy size={16} />
</div>
</button>
) : (
<div className="mt-4 text-left">
<Label>Cors settings</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
{mutation.data.cors}
</div>
<div className="text-sm italic mt-1">
You can update cors settings{' '}
<Link className="underline" href="/qwe/qwe/">
here
</Link>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant={'secondary'}
onClick={() => popModal()}
>
Close
</Button>
</DialogFooter>
</>
) : (
<>
<ModalHeader title="Create a client" />
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Label>Client name</Label>
<Input
placeholder="Eg. My App Client"
error={form.formState.errors.name?.message}
{...form.register('name')}
/>
</div>
<Controller
name="withSecret"
control={form.control}
render={({ field }) => (
<CheckboxInput
defaultChecked={!field.value}
onCheckedChange={(checked) => {
field.onChange(!checked);
}}
>
This is a website
</CheckboxInput>
)}
/>
<div>
<Label>Your domain name</Label>
<Input
placeholder="https://...."
error={form.formState.errors.domain?.message}
{...form.register('domain')}
disabled={watch}
/>
</div>
<div>
<Controller
control={form.control}
name="projectId"
render={({ field }) => {
return (
<div>
<Label>Project</Label>
<Combobox
{...field}
className="w-full"
onChange={(value) => {
field.onChange(value);
}}
items={
query.data?.map((item) => ({
value: item.id,
label: item.name,
})) ?? []
}
placeholder="Select a project"
/>
</div>
);
}}
/>
Auth with cors settings
</label>
)}
/>
{withCors && (
<>
<InputWithLabel
label="Cors"
placeholder="Cors"
{...register('cors')}
/>
<div className="text-xs text-muted-foreground mb-4 mt-1">
Restrict access by domain names (include https://)
</div>
</>
)}
<Controller
control={control}
name="projectId"
render={({ field }) => {
return (
<div>
<Label>Project</Label>
<Combobox
{...field}
onChange={(value) => {
field.onChange(value);
}}
items={
query.data?.map((item) => ({
value: item.id,
label: item.name,
})) ?? []
}
placeholder="Select a project"
/>
</div>
);
}}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Create
</Button>
</ButtonContainer>
</form>
<DialogFooter>
<Button
type="button"
variant={'secondary'}
onClick={() => popModal()}
>
Cancel
</Button>
<Button
type="submit"
icon={SaveIcon}
loading={mutation.isLoading}
>
Create
</Button>
</DialogFooter>
</form>
</>
)}
</ModalContent>
);
}
{
/* <div>
<div className="text-lg">
Select your framework and we'll generate a client for you.
</div>
<div className="flex flex-wrap gap-2 mt-8">
<FeatureButton
name="React"
logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png"
/>
<FeatureButton
name="React Native"
logo="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png"
/>
<FeatureButton
name="Next.js"
logo="https://static-00.iconduck.com/assets.00/nextjs-icon-512x512-y563b8iq.png"
/>
<FeatureButton
name="Remix"
logo="https://www.datocms-assets.com/205/1642515307-square-logo.svg"
/>
<FeatureButton
name="Vue"
logo="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ0UhQnp6TUPCwAr3ruTEwBDiTN5HLAWaoUD3AJIgtepQ&s"
/>
<FeatureButton
name="HTML"
logo="https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/HTML5_logo_and_wordmark.svg/240px-HTML5_logo_and_wordmark.svg.png"
/>
</div>
</div> */
}

View File

@@ -19,13 +19,17 @@ export function ModalContent({ children }: ModalContentProps) {
interface ModalHeaderProps {
title: string | React.ReactNode;
text?: string | React.ReactNode;
onClose?: (() => void) | false;
}
export function ModalHeader({ title, onClose }: ModalHeaderProps) {
export function ModalHeader({ title, text, onClose }: ModalHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
<div className="font-medium">{title}</div>
<div className="flex justify-between mb-6">
<div>
<div className="font-medium mt-0.5">{title}</div>
{!!text && <div className="text-sm text-muted-foreground">{text}</div>}
</div>
{onClose !== false && (
<Button
variant="ghost"

View File

@@ -146,7 +146,7 @@ function fillEmptySpotsInTimeline(
}
export function withFormula(
{ formula }: IChartInput,
{ formula, events }: IChartInput,
series: GetChartDataResult
) {
if (!formula) {
@@ -164,6 +164,33 @@ export function withFormula(
return series;
}
if (events.length === 1) {
return series.map((serie) => {
return {
...serie,
data: serie.data.map((item) => {
serie.event.id;
const scope = {
[serie.event.id]: item?.count ?? 0,
};
const count = mathjs
.parse(formula)
.compile()
.evaluate(scope) as number;
return {
...item,
count:
Number.isNaN(count) || !Number.isFinite(count)
? null
: round(count, 2),
};
}),
};
});
}
return [
{
...series[0],