Skip to content

Commit 49adff4

Browse files
committed
next: project list (WIP)
1 parent 0189e5b commit 49adff4

File tree

7 files changed

+512
-3
lines changed

7 files changed

+512
-3
lines changed

src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { WebSocketMessageValue } from '$features/websockets/models';
22

33
import { accessToken } from '$features/auth/index.svelte';
4-
import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
4+
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
55
import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query';
66

77
import type { ViewProject } from './models';
@@ -21,14 +21,23 @@ export async function invalidateProjectQueries(queryClient: QueryClient, message
2121
}
2222
}
2323

24+
// TODO: Do we need to scope these all by organization?
2425
export const queryKeys = {
26+
deleteProject: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const,
2527
deletePromotedTab: (id: string | undefined) => [...queryKeys.id(id), 'demote-tab'] as const,
2628
id: (id: string | undefined) => [...queryKeys.type, id] as const,
29+
ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const,
2730
organization: (id: string | undefined) => [...queryKeys.type, 'organization', id] as const,
2831
postPromotedTab: (id: string | undefined) => [...queryKeys.id(id), 'promote-tab'] as const,
2932
type: ['Project'] as const
3033
};
3134

35+
export interface DeleteProjectRequest {
36+
route: {
37+
ids: string[];
38+
};
39+
}
40+
3241
export interface deletePromotedTabRequest {
3342
route: {
3443
id: string;
@@ -60,6 +69,29 @@ export interface PostPromotedTabRequest {
6069
};
6170
}
6271

72+
export function deleteProject(request: DeleteProjectRequest) {
73+
const queryClient = useQueryClient();
74+
75+
return createMutation<FetchClientResponse<unknown>, ProblemDetails, void>(() => ({
76+
enabled: () => !!accessToken.current && !!request.route.ids?.length,
77+
mutationFn: async () => {
78+
const client = useFetchClient();
79+
const response = await client.delete(`projects/${request.route.ids?.join(',')}`, {
80+
expectedStatusCodes: [202]
81+
});
82+
83+
return response;
84+
},
85+
mutationKey: queryKeys.deleteProject(request.route.ids),
86+
onError: () => {
87+
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
88+
},
89+
onSuccess: () => {
90+
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
91+
}
92+
}));
93+
}
94+
6395
export function deletePromotedTab(request: deletePromotedTabRequest) {
6496
const queryClient = useQueryClient();
6597
return createMutation<boolean, ProblemDetails, { name: string }>(() => ({
@@ -91,7 +123,7 @@ export function deletePromotedTab(request: deletePromotedTabRequest) {
91123
export function getOrganizationProjectsQuery(request: GetOrganizationProjectsRequest) {
92124
const queryClient = useQueryClient();
93125

94-
return createQuery<ViewProject[], ProblemDetails>(() => ({
126+
return createQuery<FetchClientResponse<ViewProject[]>, ProblemDetails>(() => ({
95127
enabled: () => !!accessToken.current && !!request.route.organizationId,
96128
onSuccess: (data: ViewProject[]) => {
97129
data.forEach((project) => {
@@ -109,7 +141,7 @@ export function getOrganizationProjectsQuery(request: GetOrganizationProjectsReq
109141
signal
110142
});
111143

112-
return response.data!;
144+
return response;
113145
},
114146
queryKey: queryKeys.organization(request.route.organizationId)
115147
}));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<!-- filepath: /Users/blake/Projects/Exceptionless/Exceptionless/src/Exceptionless.Web/ClientApp/src/lib/features/projects/components/confirm-project-delete-AlertDialog.svelte -->
2+
<script lang="ts">
3+
import * as AlertDialog from '$comp/ui/alert-dialog';
4+
import { buttonVariants } from '$comp/ui/button';
5+
6+
interface Props {
7+
name: string;
8+
open: boolean;
9+
remove: () => Promise<void>;
10+
}
11+
12+
let { name, open = $bindable(false), remove }: Props = $props();
13+
14+
async function onSubmit() {
15+
await remove();
16+
open = false;
17+
}
18+
</script>
19+
20+
<AlertDialog.Root bind:open>
21+
<AlertDialog.Content>
22+
<AlertDialog.Header>
23+
<AlertDialog.Title>Delete Project</AlertDialog.Title>
24+
<AlertDialog.Description>
25+
Are you sure you want to delete "{name}"?
26+
</AlertDialog.Description>
27+
</AlertDialog.Header>
28+
<AlertDialog.Footer>
29+
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
30+
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={onSubmit}>Delete Project</AlertDialog.Action>
31+
</AlertDialog.Footer>
32+
</AlertDialog.Content>
33+
</AlertDialog.Root>
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import type { FetchClientResponse } from '@exceptionless/fetchclient';
2+
3+
import NumberFormatter from '$comp/formatters/number.svelte';
4+
import ProjectActionsCell from '$features/projects/components/table/project-actions-cell.svelte';
5+
import { ViewProject } from '$features/projects/models';
6+
import { DEFAULT_LIMIT } from '$shared/api/api.svelte';
7+
import {
8+
type ColumnDef,
9+
type ColumnSort,
10+
getCoreRowModel,
11+
type PaginationState,
12+
renderComponent,
13+
type RowSelectionState,
14+
type TableOptions,
15+
type Updater,
16+
type VisibilityState
17+
} from '@tanstack/svelte-table';
18+
import { PersistedState } from 'runed';
19+
import { untrack } from 'svelte';
20+
21+
import type { GetOrganizationProjectsParams, GetProjectsMode } from '../../api.svelte';
22+
23+
export function getColumns<ViewProject>(mode: GetProjectsMode = 'stats'): ColumnDef<ViewProject>[] {
24+
const columns: ColumnDef<ViewProject>[] = [
25+
{
26+
accessorKey: 'name',
27+
28+
cell: (info) => info.getValue(),
29+
enableHiding: false,
30+
header: 'Name',
31+
meta: {
32+
class: 'w-[200px]'
33+
}
34+
}
35+
];
36+
37+
const isStatsMode = mode === 'stats';
38+
if (isStatsMode) {
39+
columns.push(
40+
{
41+
accessorKey: 'stack_count',
42+
cell: (info) => renderComponent(NumberFormatter, { value: info.getValue<number>() }),
43+
header: 'Stacks',
44+
meta: {
45+
class: 'text-right w-24'
46+
}
47+
},
48+
{
49+
accessorKey: 'event_count',
50+
cell: (info) => renderComponent(NumberFormatter, { value: info.getValue<number>() }),
51+
header: 'Events',
52+
meta: {
53+
class: 'text-right w-24'
54+
}
55+
}
56+
);
57+
}
58+
59+
columns.push({
60+
cell: (info) => renderComponent(ProjectActionsCell, { project: info.row.original }),
61+
enableHiding: false,
62+
enableSorting: false,
63+
header: 'Actions',
64+
id: 'actions',
65+
meta: {
66+
class: 'w-32'
67+
}
68+
});
69+
70+
return columns;
71+
}
72+
73+
export function getTableContext<ViewProject>(
74+
params: GetOrganizationProjectsParams,
75+
configureOptions: (options: TableOptions<ViewProject>) => TableOptions<ViewProject> = (options) => options
76+
) {
77+
let _parameters = $state(params);
78+
let _pageCount = $state(0);
79+
let _columns = $state(getColumns<ViewProject>(untrack(() => _parameters.mode)));
80+
let _data = $state([] as ViewProject[]);
81+
let _loading = $state(false);
82+
let _meta = $state({} as FetchClientResponse<unknown>['meta']);
83+
84+
const [columnVisibility, setColumnVisibility] = createPersistedTableState('project-column-visibility', <VisibilityState>{});
85+
const [pagination, setPagination] = createTableState<PaginationState>({
86+
pageIndex: 0,
87+
pageSize: untrack(() => _parameters.limit) ?? DEFAULT_LIMIT
88+
});
89+
const [sorting, setSorting] = createTableState<ColumnSort[]>([
90+
{
91+
desc: true,
92+
id: 'name'
93+
}
94+
]);
95+
const [rowSelection, setRowSelection] = createTableState<RowSelectionState>({});
96+
const onPaginationChange = (updaterOrValue: Updater<PaginationState>) => {
97+
if (_loading) {
98+
return;
99+
}
100+
101+
_loading = true;
102+
setPagination(updaterOrValue);
103+
104+
const currentPageInfo = pagination();
105+
const nextLink = _meta.links?.next?.after as string;
106+
const previousLink = _meta.links?.previous?.before as string;
107+
108+
_parameters = {
109+
..._parameters,
110+
limit: currentPageInfo.pageSize,
111+
page: !nextLink && !previousLink && currentPageInfo.pageIndex !== 0 ? currentPageInfo.pageIndex + 1 : undefined
112+
};
113+
};
114+
115+
const onSortingChange = (updaterOrValue: Updater<ColumnSort[]>) => {
116+
setSorting(updaterOrValue);
117+
118+
_parameters = {
119+
..._parameters,
120+
page: undefined,
121+
sort:
122+
sorting().length > 0
123+
? sorting()
124+
.map((sort) => `${sort.desc ? '-' : ''}${sort.id}`)
125+
.join(',')
126+
: undefined
127+
};
128+
};
129+
130+
const options = configureOptions({
131+
get columns() {
132+
return _columns;
133+
},
134+
set columns(value) {
135+
_columns = value;
136+
},
137+
get data() {
138+
return _data;
139+
},
140+
set data(value) {
141+
_data = value;
142+
},
143+
enableMultiRowSelection: true,
144+
enableRowSelection: true,
145+
enableSortingRemoval: false,
146+
getCoreRowModel: getCoreRowModel(),
147+
getRowId: (originalRow) => originalRow.id,
148+
manualPagination: true,
149+
manualSorting: true,
150+
onColumnVisibilityChange: setColumnVisibility,
151+
onPaginationChange,
152+
onRowSelectionChange: setRowSelection,
153+
onSortingChange,
154+
get pageCount() {
155+
return _pageCount;
156+
},
157+
state: {
158+
get columnVisibility() {
159+
return columnVisibility();
160+
},
161+
get pagination() {
162+
return pagination();
163+
},
164+
get rowSelection() {
165+
return rowSelection();
166+
},
167+
get sorting() {
168+
return sorting();
169+
}
170+
}
171+
});
172+
173+
return {
174+
get data() {
175+
return _data;
176+
},
177+
set data(value) {
178+
_data = value;
179+
},
180+
get loading() {
181+
return _loading;
182+
},
183+
get meta() {
184+
return _meta;
185+
},
186+
set meta(value) {
187+
_meta = value;
188+
189+
const limit = _parameters.limit ?? DEFAULT_LIMIT;
190+
const total = (_meta?.total as number) ?? 0;
191+
_pageCount = Math.ceil(total / limit);
192+
193+
_loading = false;
194+
},
195+
options,
196+
get pageCount() {
197+
return _pageCount;
198+
},
199+
get parameters() {
200+
return _parameters;
201+
}
202+
};
203+
}
204+
205+
function createPersistedTableState<T>(key: string, initialValue: T): [() => T, (updater: Updater<T>) => void] {
206+
const persistedValue = new PersistedState<T>(key, initialValue);
207+
208+
return [
209+
() => persistedValue.current,
210+
(updater: Updater<T>) => {
211+
if (updater instanceof Function) {
212+
persistedValue.current = updater(persistedValue.current);
213+
} else {
214+
persistedValue.current = updater;
215+
}
216+
}
217+
];
218+
}
219+
220+
function createTableState<T>(initialValue: T): [() => T, (updater: Updater<T>) => void] {
221+
let value = $state(initialValue);
222+
223+
return [
224+
() => value,
225+
(updater: Updater<T>) => {
226+
if (updater instanceof Function) {
227+
value = updater(value);
228+
} else {
229+
value = updater;
230+
}
231+
}
232+
];
233+
}

0 commit comments

Comments
 (0)