whisper-money/resources/js/components/integration-requests/integration-requests-board.tsx

335 lines
13 KiB
TypeScript

import {
data,
removeVote,
store,
vote,
} from '@/actions/App/Http/Controllers/IntegrationRequestController';
import InputError from '@/components/input-error';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { getCsrfToken } from '@/lib/csrf';
import { __ } from '@/utils/i18n';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export interface IntegrationRequestItem {
id: string;
name: string;
url: string;
status: 'pending' | 'approved' | 'in_progress' | 'rejected' | 'not_doable';
comment: string | null;
votes_count: number;
has_voted: boolean;
can_unvote: boolean;
created_at: string;
}
interface BoardPayload {
requests: IntegrationRequestItem[];
actionsRemaining: number;
}
interface Props {
initialRequests?: IntegrationRequestItem[];
initialActionsRemaining?: number;
}
async function sendJson(
url: string,
method: 'GET' | 'POST' | 'DELETE',
body?: Record<string, string>,
): Promise<Response> {
return fetch(url, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCsrfToken(),
},
body: body ? JSON.stringify(body) : undefined,
});
}
export function IntegrationRequestsBoard({
initialRequests,
initialActionsRemaining,
}: Props) {
const [requests, setRequests] = useState<IntegrationRequestItem[] | null>(
initialRequests ?? null,
);
const [actionsRemaining, setActionsRemaining] = useState<number>(
initialActionsRemaining ?? 0,
);
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [showForm, setShowForm] = useState(false);
useEffect(() => {
if (initialRequests !== undefined) {
return;
}
void (async () => {
const response = await sendJson(data().url, 'GET');
if (response.ok) {
const payload: BoardPayload = await response.json();
setRequests(payload.requests);
setActionsRemaining(payload.actionsRemaining);
}
})();
}, [initialRequests]);
const apply = (payload: BoardPayload) => {
setRequests(payload.requests);
setActionsRemaining(payload.actionsRemaining);
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setBusy(true);
try {
const response = await sendJson(store().url, 'POST', { name, url });
if (response.ok) {
apply(await response.json());
setName('');
setUrl('');
setShowForm(false);
return;
}
const body = await response.json();
setError(body.message ?? __('Something went wrong.'));
} finally {
setBusy(false);
}
};
const handleVote = async (item: IntegrationRequestItem) => {
if (busy || item.status === 'not_doable' || actionsRemaining <= 0) {
return;
}
setError(null);
setBusy(true);
try {
const response = await sendJson(vote(item.id).url, 'POST');
if (response.ok) {
apply(await response.json());
return;
}
const body = await response.json();
setError(body.message ?? __('Something went wrong.'));
} finally {
setBusy(false);
}
};
const handleRemoveVote = async (item: IntegrationRequestItem) => {
if (busy || !item.can_unvote) {
return;
}
setError(null);
setBusy(true);
try {
const response = await sendJson(removeVote(item.id).url, 'DELETE');
if (response.ok) {
apply(await response.json());
return;
}
const body = await response.json();
setError(body.message ?? __('Something went wrong.'));
} finally {
setBusy(false);
}
};
const outOfActions = actionsRemaining <= 0;
// A new request also auto-votes it, so it costs two actions.
const cannotRequest = actionsRemaining < 2;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{__('You have :count actions left this month.', {
count: actionsRemaining,
})}
</p>
{!showForm && (
<Button
onClick={() => setShowForm(true)}
disabled={cannotRequest}
>
{__('Request integration')}
</Button>
)}
</div>
{showForm && (
<form
onSubmit={handleSubmit}
className="space-y-3 rounded-lg border p-4"
>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="integration-name">
{__('Name')}
</Label>
<Input
id="integration-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={__('e.g. Revolut')}
maxLength={255}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="integration-url">
{__('Link')}
</Label>
<Input
id="integration-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
maxLength={2048}
required
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => {
setShowForm(false);
setError(null);
}}
>
{__('Cancel')}
</Button>
<Button type="submit" disabled={busy || cannotRequest}>
{__('Submit')}
</Button>
</div>
<InputError message={error ?? undefined} />
</form>
)}
{requests === null ? (
<div className="flex justify-center py-10">
<Spinner />
</div>
) : requests.length === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
{__('No integrations requested yet. Be the first!')}
</p>
) : (
<ul className="space-y-2">
{requests.map((item) => (
<li key={item.id} className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="truncate font-medium hover:underline"
>
{item.name}
</a>
{item.status === 'pending' && (
<Badge variant="secondary">
{__('Pending review')}
</Badge>
)}
{item.status === 'in_progress' && (
<Badge>{__('In progress')}</Badge>
)}
{item.status === 'not_doable' && (
<Badge variant="outline">
{__('Not doable')}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{item.can_unvote && (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() =>
handleRemoveVote(item)
}
aria-label={__('Remove one vote')}
>
<ChevronDown className="h-4 w-4" />
</Button>
)}
<Button
variant={
item.has_voted
? 'default'
: 'outline'
}
size="sm"
disabled={
busy ||
item.status === 'not_doable' ||
outOfActions
}
onClick={() => handleVote(item)}
>
<ChevronUp className="h-4 w-4" />
{item.votes_count}
</Button>
</div>
</div>
{item.comment && (
<div className="mt-2 space-y-2 text-sm text-muted-foreground [&_a]:font-medium [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:italic [&_li]:ml-4 [&_li]:list-disc [&_strong]:font-semibold">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
}}
>
{item.comment}
</ReactMarkdown>
</div>
)}
</li>
))}
</ul>
)}
</div>
);
}