Skip to content

Commit 43534ba

Browse files
authored
FEAT: ui-state match (#573)
* wip * working ? * allow array and string * integrate ui state match in repos * exhaustive render * use ui match instead of ts-pattern * ui state: replace render by exhaustive and add nonExhaustive
1 parent 385cfba commit 43534ba

File tree

9 files changed

+124
-95
lines changed

9 files changed

+124
-95
lines changed

app/components/ui/select.tsx

+4-10
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { Portal } from '@ark-ui/react/portal';
77
import { ChevronDown, X } from 'lucide-react';
88
import { ComponentProps, ReactNode, useMemo, useState } from 'react';
99
import { isNonNullish, isNullish } from 'remeda';
10-
import { match } from 'ts-pattern';
1110

1211
import { cn } from '@/lib/tailwind/utils';
1312
import { getUiState } from '@/lib/ui-state';
@@ -157,15 +156,10 @@ export const Select = <Option extends OptionBase>({
157156
<Portal>
158157
<Combobox.Positioner>
159158
<Combobox.Content className="z-10 rounded-md bg-white p-1 shadow dark:bg-neutral-900">
160-
{match(ui.state)
161-
.with(
162-
ui.with('empty'),
163-
() => <div className="p-4">No results found</div> // TODO translate
164-
)
165-
.with(ui.with('empty-override'), ({ renderEmpty }) =>
166-
renderEmpty(search)
167-
)
168-
.with(ui.with('default'), () => (
159+
{ui
160+
.match('empty', () => <div className="p-4">No results found</div>)
161+
.match('empty-override', ({ renderEmpty }) => renderEmpty(search))
162+
.match('default', () => (
169163
<Combobox.ItemGroup className="flex flex-col gap-0.5">
170164
{collection.items.slice(0, 20).map((item) => (
171165
<Combobox.Item

app/features/repository/app/page-repositories.tsx

+5-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useInfiniteQuery } from '@tanstack/react-query';
22
import { Link } from '@tanstack/react-router';
3-
import { match } from 'ts-pattern';
43

54
import { orpc } from '@/lib/orpc/client';
65
import { getUiState } from '@/lib/ui-state';
@@ -42,11 +41,11 @@ export const PageRepositories = () => {
4241
<PageLayoutTopBarTitle>Repositories</PageLayoutTopBarTitle>
4342
</PageLayoutTopBar>
4443
<PageLayoutContent>
45-
{match(ui.state)
46-
.with(ui.with('pending'), () => <>Loading...</>) // TODO Design
47-
.with(ui.with('error'), () => <PageError />)
48-
.with(ui.with('empty'), () => <>No Repo</>) // TODO Design
49-
.with(ui.with('default'), ({ items }) => (
44+
{ui
45+
.match('pending', () => <>Loading...</>)
46+
.match('error', () => <PageError />)
47+
.match('empty', () => <>No repo</>)
48+
.match('default', ({ items }) => (
5049
<>
5150
{items.map((item) => (
5251
<Link

app/features/repository/app/page-repository.tsx

+9-12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { ORPCError } from '@orpc/client';
22
import { useQuery } from '@tanstack/react-query';
33
import { AlertCircleIcon } from 'lucide-react';
4-
import { match } from 'ts-pattern';
54

65
import { orpc } from '@/lib/orpc/client';
76
import { getUiState } from '@/lib/ui-state';
@@ -55,23 +54,21 @@ export const PageRepository = (props: { params: { id: string } }) => {
5554
}
5655
>
5756
<PageLayoutTopBarTitle>
58-
{match(ui.state)
59-
.with(ui.with('pending'), () => <Skeleton className="h-4 w-48" />)
60-
.with(ui.with('not-found'), ui.with('error'), () => (
57+
{ui
58+
.match('pending', () => <Skeleton className="h-4 w-48" />)
59+
.match(['not-found', 'error'], () => (
6160
<AlertCircleIcon className="size-4 text-muted-foreground" />
6261
))
63-
.with(ui.with('default'), ({ repository }) => (
64-
<>{repository.name}</>
65-
))
62+
.match('default', ({ repository }) => <>{repository.name}</>)
6663
.exhaustive()}
6764
</PageLayoutTopBarTitle>
6865
</PageLayoutTopBar>
6966
<PageLayoutContent>
70-
{match(ui.state)
71-
.with(ui.with('pending'), () => <Spinner full />)
72-
.with(ui.with('not-found'), () => <PageError errorCode={404} />)
73-
.with(ui.with('error'), () => <PageError />)
74-
.with(ui.with('default'), ({ repository }) => <>{repository.name}</>)
67+
{ui
68+
.match('pending', () => <Spinner full />)
69+
.match('not-found', () => <PageError errorCode={404} />)
70+
.match('error', () => <PageError />)
71+
.match('default', ({ repository }) => <>{repository.name}</>)
7572
.exhaustive()}
7673
</PageLayoutContent>
7774
</PageLayout>

app/features/repository/manager/page-repositories.tsx

+6-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useInfiniteQuery } from '@tanstack/react-query';
22
import { Link, useRouter } from '@tanstack/react-router';
33
import { BookMarkedIcon, PlusIcon } from 'lucide-react';
4-
import { match } from 'ts-pattern';
54

65
import { orpc } from '@/lib/orpc/client';
76
import { getUiState } from '@/lib/ui-state';
@@ -97,16 +96,16 @@ export const PageRepositories = (props: {
9796
</PageLayoutTopBar>
9897
<PageLayoutContent className="pb-20">
9998
<DataList>
100-
{match(ui.state)
101-
.with(ui.with('pending'), () => <DataListLoadingState />)
102-
.with(ui.with('error'), () => (
99+
{ui
100+
.match('pending', () => <DataListLoadingState />)
101+
.match('error', () => (
103102
<DataListErrorState retry={() => repositoriesQuery.refetch()} />
104103
))
105-
.with(ui.with('empty'), () => <DataListEmptyState />)
106-
.with(ui.with('empty-search'), ({ searchTerm }) => (
104+
.match('empty', () => <DataListEmptyState />)
105+
.match('empty-search', ({ searchTerm }) => (
107106
<DataListEmptyState searchTerm={searchTerm} />
108107
))
109-
.with(ui.with('default'), ({ items, searchTerm, total }) => (
108+
.match('default', ({ items, searchTerm, total }) => (
110109
<>
111110
{!!searchTerm && (
112111
<DataListRowResults

app/features/repository/manager/page-repository.tsx

+9-12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { ORPCError } from '@orpc/client';
22
import { useQuery } from '@tanstack/react-query';
33
import { AlertCircleIcon, PencilLineIcon, Trash2Icon } from 'lucide-react';
4-
import { match } from 'ts-pattern';
54

65
import { orpc } from '@/lib/orpc/client';
76
import { getUiState } from '@/lib/ui-state';
@@ -54,23 +53,21 @@ export const PageRepository = (props: { params: { id: string } }) => {
5453
}
5554
>
5655
<PageLayoutTopBarTitle>
57-
{match(ui.state)
58-
.with(ui.with('pending'), () => <Skeleton className="h-4 w-48" />)
59-
.with(ui.with('not-found'), ui.with('error'), () => (
56+
{ui
57+
.match('pending', () => <Skeleton className="h-4 w-48" />)
58+
.match(['not-found', 'error'], () => (
6059
<AlertCircleIcon className="size-4 text-muted-foreground" />
6160
))
62-
.with(ui.with('default'), ({ repository }) => (
63-
<>{repository.name}</>
64-
))
61+
.match('default', ({ repository }) => <>{repository.name}</>)
6562
.exhaustive()}
6663
</PageLayoutTopBarTitle>
6764
</PageLayoutTopBar>
6865
<PageLayoutContent>
69-
{match(ui.state)
70-
.with(ui.with('pending'), () => <Spinner full />)
71-
.with(ui.with('not-found'), () => <PageError errorCode={404} />)
72-
.with(ui.with('error'), () => <PageError />)
73-
.with(ui.with('default'), ({ repository }) => <>{repository.name}</>)
66+
{ui
67+
.match('pending', () => <Spinner full />)
68+
.match('not-found', () => <PageError errorCode={404} />)
69+
.match('error', () => <PageError />)
70+
.match('default', ({ repository }) => <>{repository.name}</>)
7471
.exhaustive()}
7572
</PageLayoutContent>
7673
</PageLayout>

app/features/user/manager/page-user-update.tsx

+4-7
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { useBlocker, useCanGoBack, useRouter } from '@tanstack/react-router';
55
import { AlertCircleIcon } from 'lucide-react';
66
import { useForm } from 'react-hook-form';
77
import { toast } from 'sonner';
8-
import { match } from 'ts-pattern';
98

109
import { authClient } from '@/lib/auth/client';
1110
import { orpc } from '@/lib/orpc/client';
@@ -133,14 +132,12 @@ export const PageUserUpdate = (props: { params: { id: string } }) => {
133132
}
134133
>
135134
<PageLayoutTopBarTitle>
136-
{match(ui.state)
137-
.with(ui.with('pending'), () => <Skeleton className="h-4 w-48" />)
138-
.with(ui.with('not-found'), ui.with('error'), () => (
135+
{ui
136+
.match('pending', () => <Skeleton className="h-4 w-48" />)
137+
.match(['not-found', 'error'], () => (
139138
<AlertCircleIcon className="size-4 text-muted-foreground" />
140139
))
141-
.with(ui.with('default'), ({ user }) => (
142-
<>{user.name || user.email}</>
143-
))
140+
.match('default', ({ user }) => <>{user.name || user.email}</>)
144141
.exhaustive()}
145142
</PageLayoutTopBarTitle>
146143
</PageLayoutTopBar>

app/features/user/manager/page-user.tsx

+14-18
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { Link, useCanGoBack, useRouter } from '@tanstack/react-router';
99
import dayjs from 'dayjs';
1010
import { AlertCircleIcon, PencilLineIcon, Trash2Icon } from 'lucide-react';
1111
import { toast } from 'sonner';
12-
import { match } from 'ts-pattern';
1312

1413
import { authClient } from '@/lib/auth/client';
1514
import { orpc } from '@/lib/orpc/client';
@@ -140,23 +139,21 @@ export const PageUser = (props: { params: { id: string } }) => {
140139
}
141140
>
142141
<PageLayoutTopBarTitle>
143-
{match(ui.state)
144-
.with(ui.with('pending'), () => <Skeleton className="h-4 w-48" />)
145-
.with(ui.with('not-found'), ui.with('error'), () => (
142+
{ui
143+
.match('pending', () => <Skeleton className="h-4 w-48" />)
144+
.match(['not-found', 'error'], () => (
146145
<AlertCircleIcon className="size-4 text-muted-foreground" />
147146
))
148-
.with(ui.with('default'), ({ user }) => (
149-
<>{user.name || user.email}</>
150-
))
147+
.match('default', ({ user }) => <>{user.name || user.email}</>)
151148
.exhaustive()}
152149
</PageLayoutTopBarTitle>
153150
</PageLayoutTopBar>
154151
<PageLayoutContent>
155-
{match(ui.state)
156-
.with(ui.with('pending'), () => <Spinner full />)
157-
.with(ui.with('not-found'), () => <PageError errorCode={404} />)
158-
.with(ui.with('error'), () => <PageError />)
159-
.with(ui.with('default'), ({ user }) => (
152+
{ui
153+
.match('pending', () => <Spinner full />)
154+
.match('not-found', () => <PageError errorCode={404} />)
155+
.match('error', () => <PageError />)
156+
.match('default', ({ user }) => (
160157
<div className="flex flex-col gap-4 xl:flex-row xl:items-start">
161158
<Card className="relative flex-1">
162159
<CardHeader>
@@ -268,18 +265,17 @@ const UserSessions = (props: { userId: string }) => {
268265
</DataListCell>
269266
</WithPermissions>
270267
</DataListRow>
271-
272-
{match(ui.state)
273-
.with(ui.with('pending'), () => <DataListLoadingState />)
274-
.with(ui.with('error'), () => (
268+
{ui
269+
.match('pending', () => <DataListLoadingState />)
270+
.match('error', () => (
275271
<DataListErrorState retry={() => sessionsQuery.refetch()} />
276272
))
277-
.with(ui.with('empty'), () => (
273+
.match('empty', () => (
278274
<DataListEmptyState className="min-h-20">
279275
No user sessions
280276
</DataListEmptyState>
281277
))
282-
.with(ui.with('default'), ({ items }) => (
278+
.match('default', ({ items }) => (
283279
<>
284280
{items.map((item) => (
285281
<DataListRow

app/features/user/manager/page-users.tsx

+6-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useInfiniteQuery } from '@tanstack/react-query';
22
import { Link, useRouter } from '@tanstack/react-router';
33
import dayjs from 'dayjs';
44
import { PlusIcon } from 'lucide-react';
5-
import { match } from 'ts-pattern';
65

76
import { orpc } from '@/lib/orpc/client';
87
import { cn } from '@/lib/tailwind/utils';
@@ -103,16 +102,16 @@ export const PageUsers = (props: { search: { searchTerm?: string } }) => {
103102
</PageLayoutTopBar>
104103
<PageLayoutContent className="pb-20">
105104
<DataList>
106-
{match(ui.state)
107-
.with(ui.with('pending'), () => <DataListLoadingState />)
108-
.with(ui.with('error'), () => (
105+
{ui
106+
.match('pending', () => <DataListLoadingState />)
107+
.match('error', () => (
109108
<DataListErrorState retry={() => usersQuery.refetch()} />
110109
))
111-
.with(ui.with('empty'), () => <DataListEmptyState />)
112-
.with(ui.with('empty-search'), ({ searchTerm }) => (
110+
.match('empty', () => <DataListEmptyState />)
111+
.match('empty-search', ({ searchTerm }) => (
113112
<DataListEmptyState searchTerm={searchTerm} />
114113
))
115-
.with(ui.with('default'), ({ items, searchTerm, total }) => (
114+
.match('default', ({ items, searchTerm, total }) => (
116115
<>
117116
{!!searchTerm && (
118117
<DataListRowResults

app/lib/ui-state.ts

+67-16
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@ type AvailableStatus =
77
| 'default'
88
| (string & {}); // Allows extra status
99

10+
type UiState<
11+
Status extends AvailableStatus,
12+
Data extends Record<string, unknown>,
13+
> = {
14+
is: <S extends Status>(status: S) => boolean;
15+
state: {
16+
__status: Status;
17+
} & Data;
18+
match: <S extends Status>(
19+
status: S | Array<S>,
20+
handler: (
21+
data: Omit<
22+
Extract<UiState<Status, Data>['state'], { __status: S }>,
23+
'__status'
24+
>
25+
) => React.ReactNode,
26+
__matched?: boolean,
27+
run?: () => React.ReactNode
28+
) => {
29+
nonExhaustive: () => React.ReactNode;
30+
} & (Exclude<Status, S> extends never
31+
? {
32+
exhaustive: () => React.ReactNode;
33+
}
34+
: Pick<UiState<Exclude<Status, S>, Data>, 'match'>);
35+
};
36+
1037
export const getUiState = <
1138
Status extends AvailableStatus,
1239
Data extends Record<string, unknown>,
@@ -17,30 +44,54 @@ export const getUiState = <
1744
data?: D
1845
) => { __status: S } & D
1946
) => { __status: Status } & Data
20-
): {
21-
with: <S extends Status>(
22-
status: S
23-
) => {
24-
__status: S;
25-
};
26-
is: <S extends Status>(status: S) => boolean;
27-
state: {
28-
__status: Status;
29-
} & Data;
30-
} => {
47+
): UiState<Status, Data> => {
3148
const state = getState((status, data = {} as ExplicitAny) => {
3249
return {
3350
__status: status,
3451
...data,
3552
};
3653
});
37-
return {
38-
with: (status) => ({
39-
__status: status,
40-
}),
54+
55+
const isMatching = <S extends Status>(status: Status): status is S =>
56+
status === state.__status;
57+
58+
const isMatchingArray = <S extends Status>(
59+
status: Array<Status>
60+
): status is Array<S> => status.includes(state.__status);
61+
62+
const uiState: UiState<Status, Data> = {
63+
state,
4164
is: (status) => {
4265
return state.__status === status;
4366
},
44-
state,
67+
match: (status, handler, __matched = false, render = () => null) => {
68+
if (
69+
!__matched && typeof status === 'string'
70+
? isMatching(status)
71+
: isMatchingArray(status as Array<Status>)
72+
) {
73+
return {
74+
...(uiState as ExplicitAny),
75+
__matched: true,
76+
exhaustive: () => handler(state as ExplicitAny),
77+
nonExhaustive: () => handler(state as ExplicitAny),
78+
match: (status, _handler) =>
79+
uiState.match(status, _handler, true, () =>
80+
handler(uiState.state as ExplicitAny)
81+
),
82+
};
83+
}
84+
85+
return {
86+
...uiState,
87+
__matched,
88+
exhaustive: render,
89+
nonExhaustive: render,
90+
match: (status, handler) =>
91+
uiState.match(status, handler, __matched, render),
92+
};
93+
},
4594
};
95+
96+
return uiState;
4697
};

0 commit comments

Comments
 (0)