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

refactor: move notes example to tanstack query #249

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/app/src/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { provideFileRouter } from '@analogjs/router';
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { PreloadAllModules, withInMemoryScrolling, withNavigationErrorHandler, withPreloading } from '@angular/router';
import { QueryClient, provideAngularQuery } from '@tanstack/angular-query-experimental';
import { provideTrpcClient } from './trpc-client';

export const appConfig: ApplicationConfig = {
Expand All @@ -13,5 +14,6 @@ export const appConfig: ApplicationConfig = {
),
provideClientHydration(),
provideTrpcClient(),
provideAngularQuery(new QueryClient()),
],
};
155 changes: 45 additions & 110 deletions apps/app/src/app/pages/(examples)/examples/notes/(notes).page.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import { RouteMeta } from '@analogjs/router';
import { AsyncPipe, DatePipe, JsonPipe, NgFor, NgIf } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { waitFor } from '@spartan-ng/trpc';
import { HlmButtonDirective } from '@spartan-ng/ui-button-helm';
import { HlmInputDirective } from '@spartan-ng/ui-input-helm';
import { HlmLabelDirective } from '@spartan-ng/ui-label-helm';
import { HlmSpinnerComponent } from '@spartan-ng/ui-spinner-helm';
import { SignalFormBuilder, SignalInputDirective, V, withErrorComponent } from 'ng-signal-forms';
import { Observable, Subject, catchError, of, switchMap, take, tap } from 'rxjs';
import { Note } from '../../../../../db';
import { injectTRPCClient } from '../../../../../trpc-client';
import { InputErrorComponent } from '../../../../shared/input-error/input-error.component';
import { SpartanInputErrorDirective } from '../../../../shared/input-error/input-error.directive';
import { metaWith } from '../../../../shared/meta/meta.util';
import { NoteSkeletonComponent } from './components/note-skeleton.component';
import { NoteComponent } from './components/note.component';
import { NotesEmptyComponent } from './components/notes-empty.component';
import { injectCreateNoteMutation, injectDeleteNoteMutation } from './notes.mutations';
import { injectNotesQuery } from './notes.queries';

export const routeMeta: RouteMeta = {
meta: metaWith('spartan/examples - Notes', 'A notes example displaying the SPARTAN stack and new UI primitives'),
Expand All @@ -28,17 +27,12 @@ export const routeMeta: RouteMeta = {
selector: 'spartan-notes-example',
standalone: true,
imports: [
AsyncPipe,
FormsModule,
NgFor,
DatePipe,
NgIf,
JsonPipe,

RouterLink,
SignalInputDirective,
SpartanInputErrorDirective,

RouterLink,

HlmButtonDirective,
HlmLabelDirective,
HlmInputDirective,
Expand All @@ -47,13 +41,14 @@ export const routeMeta: RouteMeta = {
NoteSkeletonComponent,
NotesEmptyComponent,
HlmSpinnerComponent,
JsonPipe,
],
providers: [withErrorComponent(InputErrorComponent)],
host: {
class: 'block p-2 sm:p-4 pb-16',
},
template: `
<form class="flex flex-col items-end py-2">
<form class="flex flex-col items-end py-2" (ngSubmit)="handleSubmit($event)">
<label hlmLabel class="w-full">
Title
<input
Expand All @@ -63,7 +58,7 @@ export const routeMeta: RouteMeta = {
autocomplete="off"
name="newTitle"
ngModel
[formField]="form.controls.title"
[formField]="_form.controls.title"
/>
</label>

Expand All @@ -77,80 +72,43 @@ export const routeMeta: RouteMeta = {
name="newContent"
ngModel
rows="4"
[formField]="form.controls.content"
[formField]="_form.controls.content"
></textarea>
</label>

<button hlmBtn [disabled]="createLoad()" variant="secondary" (click)="createNote()">
<span>{{ createLoad() ? 'Creating' : 'Create' }} Note</span>
<hlm-spinner *ngIf="createLoad()" class="ml-2" size="sm" />
<button hlmBtn variant="secondary">
<span>{{ _createMutation.isPending() ? 'Creating' : 'Create' }} Note</span>
@if (_createMutation.isPending()) {
<hlm-spinner class="ml-2 h-5 w-5" size="sm" />
}
</button>
</form>
<div class="flex flex-col space-y-4 pb-12 pt-4">
<ng-container *ngIf="showNotesArray()">
@for (note of _notesQ.data() ?? []; track note.id) {
<analog-trpc-note
*ngFor="let note of state().notes ?? []; trackBy: noteTrackBy"
[note]="note"
[deletionInProgress]="deleteIdInProgress() === note.id"
(deleteClicked)="deleteNote(note.id)"
[deletionInProgress]="_deleteMutation.isPending() && _noteBeingDeleted()?.id === note.id"
(deleteClicked)="deleteNote(note)"
/>
<analog-trpc-notes-empty class="border-transparent shadow-none" *ngIf="noNotes()"></analog-trpc-notes-empty>
</ng-container>

<analog-trpc-note-skeleton *ngIf="initialLoad() || createLoad()" />
} @empty {
@if (!_notesQ.isLoading()) {
<analog-trpc-notes-empty class="border-transparent shadow-none" />
} @else {
<analog-trpc-note-skeleton />
}
}
</div>
`,
})
export default class NotesExamplePageComponent {
private _trpc = injectTRPCClient();
private _sfb = inject(SignalFormBuilder);
private _refreshNotes$ = new Subject<void>();
private _notes$ = this._refreshNotes$.pipe(
switchMap(() => this._trpc.note.list.query()),
tap((result) =>
this.state.update((state) => ({
...state,
status: 'success',
notes: result,
error: null,
})),
),
catchError((err) => {
this.state.update((state) => ({
...state,
notes: [],
status: 'error',
error: err,
}));
return of([]);
}),
);
protected readonly _notesQ = injectNotesQuery();

public state = signal<{
status: 'idle' | 'loading' | 'success' | 'error';
notes: Note[];
error: unknown | null;
updatedFrom: 'initial' | 'create' | 'delete';
idBeingDeleted?: number;
}>({
status: 'idle',
notes: [],
error: null,
updatedFrom: 'initial',
});
public initialLoad = computed(() => this.state().status === 'loading' && this.state().updatedFrom === 'initial');
public createLoad = computed(() => this.state().status === 'loading' && this.state().updatedFrom === 'create');
public deleteIdInProgress = computed(() =>
this.state().status === 'loading' && this.state().updatedFrom === 'delete'
? this.state().idBeingDeleted
: undefined,
);
public noNotes = computed(() => this.state().notes.length === 0);
public showNotesArray = computed(
() => this.state().updatedFrom === 'delete' || this.state().notes.length > 0 || this.state().status === 'success',
);
protected readonly _noteBeingDeleted = signal<Note | undefined>(undefined);
protected readonly _deleteMutation = injectDeleteNoteMutation();

public form = this._sfb.createFormGroup(() => ({
protected readonly _createMutation = injectCreateNoteMutation();
private readonly _sfb = inject(SignalFormBuilder);
protected readonly _form = this._sfb.createFormGroup(() => ({
title: this._sfb.createFormField<string>('', {
validators: [
{
Expand All @@ -169,46 +127,23 @@ export default class NotesExamplePageComponent {
}),
}));

constructor() {
this._notes$.subscribe();
void waitFor(this._notes$);
this.updateNotes('initial');
}

public noteTrackBy = (index: number, note: Note) => {
return note.id;
};

public createNote() {
if (this.form.state() !== 'VALID') {
this.form.markAllAsTouched();
public handleSubmit(event: SubmitEvent) {
event.preventDefault();
event.stopPropagation();
if (!this._form.valid()) {
this._form.markAllAsTouched();
return;
}
const { title, content } = this.form.value();
this.updateNotes('create', this._trpc.note.create.mutate({ title, content }));
this.form.reset();
void this._createMutation.mutate(this._form.value(), { onSuccess: () => this._form.reset() });
}

public deleteNote(id: number) {
this.updateNotes('delete', this._trpc.note.remove.mutate({ id }), id);
}

private updateNotes(
updatedFrom: 'initial' | 'create' | 'delete',
operation?: Observable<Note | Note[]>,
idBeingDeleted?: number,
) {
this.state.update((state) => ({
status: 'loading',
notes: state.notes,
error: null,
updatedFrom,
idBeingDeleted,
}));
if (!operation) {
this._refreshNotes$.next();
return;
}
operation.pipe(take(1)).subscribe(() => this._refreshNotes$.next());
public deleteNote(note: Note) {
this._noteBeingDeleted.set(note);
this._deleteMutation.mutate(
{ id: note.id },
{
onSuccess: () => this._noteBeingDeleted.set(undefined),
},
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const notesKeys = {
list: ['notes', 'list'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { injectMutation } from '@tanstack/angular-query-experimental';
import { assertInjector } from 'ngxtension/assert-injector';
import { lastValueFrom } from 'rxjs';
import { injectTRPCClient } from '../../../../../trpc-client';
import { notesKeys } from './notes.keys';

export interface CreateNoteParams {
title: string;
content: string;
}
export const injectCreateNoteMutation = ({ injector }: { injector?: Injector } = {}) => {
injector = assertInjector(injectCreateNoteMutation, injector);
return runInInjectionContext(injector, () => {
const trpc = injectTRPCClient();
return injectMutation((client) => ({
mutationFn: ({ title, content }: CreateNoteParams) => lastValueFrom(trpc.note.create.mutate({ title, content })),
// Invalidate and refetch by using the client directly
onSuccess: () => client.invalidateQueries({ queryKey: notesKeys.list }),
}));
});
};

export interface DeleteNoteParams {
id: number;
}
export const injectDeleteNoteMutation = ({ injector }: { injector?: Injector } = {}) => {
injector = assertInjector(injectDeleteNoteMutation, injector);
return runInInjectionContext(injector, () => {
const trpc = injectTRPCClient();
return injectMutation((client) => ({
mutationFn: ({ id }: DeleteNoteParams) => lastValueFrom(trpc.note.remove.mutate({ id })),
// Invalidate and refetch by using the client directly
onSuccess: () => client.invalidateQueries({ queryKey: notesKeys.list }),
}));
});
};
19 changes: 19 additions & 0 deletions apps/app/src/app/pages/(examples)/examples/notes/notes.queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { injectQuery, keepPreviousData } from '@tanstack/angular-query-experimental';
import { assertInjector } from 'ngxtension/assert-injector';
import { lastValueFrom } from 'rxjs';
import { Note } from '../../../../../db';
import { injectTRPCClient } from '../../../../../trpc-client';
import { notesKeys } from './notes.keys';

export const injectNotesQuery = ({ injector }: { injector?: Injector } = {}) => {
injector = assertInjector(injectNotesQuery, injector);
return runInInjectionContext(injector, () => {
const trpc = injectTRPCClient();
return injectQuery(() => ({
queryKey: notesKeys.list,
queryFn: () => lastValueFrom<Note[]>(trpc.note.list.query()),
placeholderData: keepPreviousData,
}));
});
};
10 changes: 9 additions & 1 deletion apps/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ export default defineConfig(({ mode }) => {
include: ['@angular/common', '@angular/forms', 'isomorphic-fetch'],
},
ssr: {
noExternal: ['@spartan-ng/**', '@angular/cdk/**', '@ng-icons/**', 'ngx-scrollbar/**', 'ng-signal-forms/**'],
noExternal: [
'@spartan-ng/**',
'@angular/cdk/**',
'@ng-icons/**',
'ngx-scrollbar/**',
'ng-signal-forms/**',
'@tanstack/**',
'ngxtension/**',
],
},
build: {
outDir: '../../dist/apps/app/client',
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,14 @@
"@angular/platform-server": "17.3.0",
"@angular/router": "17.3.0",
"@ng-icons/core": "~25.2.0",
"@ngxpert/cmdk": "^1.0.0",
"@ngneat/overview": "^5.0.0",
"@ngneat/until-destroy": "^10.0.0",
"@ngxpert/cmdk": "^1.0.0",
"@nx/angular": "18.0.8",
"@nx/devkit": "18.0.8",
"@nx/plugin": "18.0.8",
"@swc/helpers": "0.5.3",
"@tanstack/angular-query-experimental": "^5.28.13",
"@testing-library/cypress": "^9.0.0",
"@trpc/client": "~10.26.0",
"@trpc/server": "~10.26.0",
Expand All @@ -66,6 +67,7 @@
"ng-signal-forms": "^1.4.0",
"ngx-scrollbar": "^13.0.1",
"ngx-sonner": "^0.3.4",
"ngxtension": "^2.2.1",
"ofetch": "^1.0.1",
"ohash": "^1.0.0",
"postgres": "^3.3.5",
Expand Down Expand Up @@ -164,11 +166,11 @@
"postcss-preset-env": "~8.0.1",
"postcss-url": "~10.1.3",
"prettier": "^3.1.0",
"rollup-plugin-visualizer": "^5.9.0",
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.7",
"replace-json-property": "^1.9.0",
"rollup-plugin-typescript-paths": "^1.5.0",
"rollup-plugin-visualizer": "^5.9.0",
"semantic-release": "^21.0.7",
"start-server-and-test": "^1.15.4",
"tailwindcss": "^3.0.2",
Expand Down