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

feat(router): introduce server support for form actions #1346

Merged
merged 11 commits into from
Oct 3, 2024
78 changes: 78 additions & 0 deletions apps/analog-app/src/app/pages/form-action.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Directive, inject, input, output } from '@angular/core';
import { Params, Router } from '@angular/router';

export type ActionResult = {
type: string;
};

@Directive({
selector: 'form[action],form[method]',
host: {
'(submit)': `submitted($event)`,
},
standalone: true,
})
export class FormAction {
action = input<string>('');
onSuccess = output<unknown>();
onError = output<unknown>();
state = output<
'submitting' | 'error' | 'redirect' | 'success' | 'navigate'
>();
router = inject(Router);

submitted($event: { target: HTMLFormElement } & Event) {
this.state.emit('submitting');
$event.preventDefault();
const body = new FormData($event.target);

This comment was marked as resolved.

const path = window.location.pathname;

if ($event.target.method.toUpperCase() === 'GET') {
const params: Params = {};
body.forEach((formVal, formKey) => {
params[formKey] = formVal;
});
this.state.emit('navigate');
this.router.navigate([path], {
queryParams: params,
onSameUrlNavigation: 'reload',
});
} else {
fetch(this.action() || `/api/_analog/pages${path}`, {
method: $event.target.method,
body,
})
.then((res) => {
if (res.ok) {
if (res.redirected) {
const redirectUrl = new URL(res.url).pathname;
this.state.emit('redirect');
this.router.navigate([redirectUrl]);
} else if (res.headers.get('Content-type') === 'application/json') {
res.json().then((result) => {
this.onSuccess.emit(result);
this.state.emit('success');
});
} else {
res.text().then((result) => {
this.onSuccess.emit(result);
this.onSuccess.emit('success');

Choose a reason for hiding this comment

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

shouldn't it be this.state.emit('success'); ?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep

});
}
} else {
if (res.headers.get('X-Analog-Errors')) {
res.json().then((errors: unknown) => {
this.onError.emit(errors);
this.state.emit('error');
});
} else {
this.state.emit('error');
}
}
})
.catch((_) => {
this.state.emit('error');
});
}
}
}
52 changes: 52 additions & 0 deletions apps/analog-app/src/app/pages/newsletter.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Component, signal } from '@angular/core';

import { FormAction } from './form-action.directive';

type FormErrors =
| {
email?: string;
}
| undefined;

@Component({
selector: 'app-newsletter-page',
standalone: true,
imports: [FormAction],
template: `
<h3>Newsletter Signup</h3>

@if (!signedUp()) {
<form
method="post"
(onSuccess)="onSuccess()"
(onError)="onError($any($event))"
(onStateChange)="errors.set(undefined)"
>
<div>
<label for="email"> Email </label>
<input type="email" name="email" />
</div>

<button class="button" type="submit">Submit</button>
</form>

@if( errors()?.email ) {
<p>{{ errors()?.email }}</p>
} } @else {
<div>Thanks for signing up!</div>
}
`,
})
export default class NewsletterComponent {
signedUp = signal(false);
errors = signal<FormErrors>(undefined);

onSuccess() {
this.signedUp.set(true);
}

onError(result?: FormErrors) {
this.errors.set(result);
console.log({ result });
}
}
30 changes: 30 additions & 0 deletions apps/analog-app/src/app/pages/newsletter.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
type PageServerAction,
redirect,
json,
fail,
} from '@analogjs/router/server/actions';
import { readFormData } from 'h3';

export function load() {
return {
loaded: true,
};
}

export async function action({ event }: PageServerAction) {
const body = await readFormData(event);
const email = body.get('email') as string;

if (!email) {
return fail(422, { email: 'Email is required' });
}

if (email.length < 10) {
return redirect('/');
}

console.log({ email: body.get('email') });

return json({ type: 'success' });
}
32 changes: 32 additions & 0 deletions apps/analog-app/src/app/pages/search.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Component, computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectLoad } from '@analogjs/router';

import { FormAction } from './form-action.directive';
import type { load } from './search.server';

@Component({
selector: 'app-search-page',
standalone: true,
imports: [FormAction],
template: `
<h3>Search</h3>

<form method="get">
<div>
<label for="search"> Search </label>
<input type="text" name="search" [value]="searchTerm()" />
</div>

<button class="button" type="submit">Submit</button>
</form>

@if(searchTerm()) {
<p>Search Term: {{ searchTerm() }}</p>
}
`,
})
export default class NewsletterComponent {
loader = toSignal(injectLoad<typeof load>(), { requireSync: true });
searchTerm = computed(() => this.loader().searchTerm);
}
12 changes: 12 additions & 0 deletions apps/analog-app/src/app/pages/search.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { PageServerLoad } from '@analogjs/router';
import { getQuery } from 'h3';

export async function load({ event }: PageServerLoad) {
const query = getQuery(event);
console.log('loaded search', query['search']);

return {
loaded: true,
searchTerm: `${query['search']}`,
};
}
5 changes: 5 additions & 0 deletions packages/router/server/actions/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
46 changes: 46 additions & 0 deletions packages/router/server/actions/src/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { H3Event, H3EventContext } from 'h3';
import type { $Fetch } from 'nitropack';

export type PageServerAction = {
params: H3EventContext['params'];
req: H3Event['node']['req'];
res: H3Event['node']['res'];
fetch: $Fetch;
event: H3Event;
};

export function fail<T = object>(status: number, errors: T) {
return new Response(JSON.stringify(errors), {
status,
headers: {
'X-Analog-Errors': 'true',
},
});
}

export function json<T = object>(data: T, config?: ResponseInit) {
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
...config,
});
}

export function redirect(url: string, config: number | ResponseInit = 302) {
if (typeof config === 'number') {
return new Response(null, {
status: config,
headers: {
Location: `${url}`,
},
});
}

return new Response(null, {
headers: {
Location: `${url}`,
},
...config,
});
}
1 change: 1 addition & 0 deletions packages/router/server/actions/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PageServerAction, json, redirect, fail } from './actions';
2 changes: 2 additions & 0 deletions packages/router/src/lib/route-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export function toRouteConfig(routeMeta: RouteMeta | undefined): RouteConfig {
routeConfig = {};
}

routeConfig.runGuardsAndResolvers =
routeConfig.runGuardsAndResolvers ?? 'paramsOrQueryParamsChange';
routeConfig.resolve = {
...routeConfig.resolve,
load: async (route) => {
Expand Down
47 changes: 36 additions & 11 deletions packages/vite-plugin-nitro/src/lib/plugins/page-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,43 @@ export function pageEndpointsPlugin() {
}`
}

${
fileExports.includes('action')
? ''
: `
export const action = () => {
return {};
}
`
}

export default defineEventHandler(async(event) => {
try {
return await load({
params: event.context.params,
req: event.node.req,
res: event.node.res,
fetch: $fetch,
event
});
} catch(e) {
console.error(\` An error occurred: \$\{e\}\`)
throw e;
if (event.method === 'GET') {
try {
return await load({
params: event.context.params,
req: event.node.req,
res: event.node.res,
fetch: $fetch,
event
});
} catch(e) {
console.error(\` An error occurred: \$\{e\}\`)
throw e;
}
} else {
try {
return await action({
params: event.context.params,
req: event.node.req,
res: event.node.res,
fetch: $fetch,
event
});
} catch(e) {
console.error(\` An error occurred: \$\{e\}\`)
throw e;
}
}
});
`;
Expand Down
Loading