Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { useState, type ReactNode } from 'react';
import {
CURRENCIES,
LOCALE_LABELS,
SUPPORTED_LOCALES,
currencySymbol,
isLocale,
} from '@wellformed/shared';
import { api } from '../api/client';
import { useI18n } from '../i18n';
import { useSession } from '../state/session';
import { useToasts } from '../state/toasts';
import { goTo, workspacePath } from '../state/route';
import { Modal } from './Modal';
/**
* Settings for the open workspace: what it is called, where it lives, how to
* get the data out, and how to leave or end it.
*
* Export is a plain link rather than a fetch because the response is a file —
* letting the browser do what it is good at beats holding a database in a blob.
*/
export function WorkspaceDialog({ onClose }: { onClose(): void }): ReactNode {
const session = useSession();
const toasts = useToasts();
const { t } = useI18n();
const workspace = session.workspace!;
const isAdmin = session.can('admin');
const [name, setName] = useState(workspace.name);
const [slug, setSlug] = useState(workspace.slug);
const [currency, setCurrency] = useState(workspace.currency);
const [locale, setLocale] = useState(workspace.locale);
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
// A workspace on a currency the list does not carry keeps it, and keeps it
// selectable: the dropdown is a shortcut, not the set of allowed answers.
const currencies = CURRENCIES.some((c) => c.code === workspace.currency)
? CURRENCIES
: [{ code: workspace.currency, name: workspace.currency }, ...CURRENCIES];
const changed =
name.trim() !== workspace.name ||
slug !== workspace.slug ||
currency !== workspace.currency ||
locale !== workspace.locale;
async function save() {
setBusy(true);
try {
const { workspace: updated } = await api.updateWorkspace({
...(name.trim() !== workspace.name ? { name: name.trim() } : {}),
...(slug !== workspace.slug ? { slug } : {}),
...(currency !== workspace.currency ? { currency } : {}),
...(locale !== workspace.locale ? { locale } : {}),
});
// The address is part of the URL, so a changed slug means this page is
// now at the wrong one.
if (updated.slug !== workspace.slug) {
goTo(workspacePath(updated.slug));
return;
}
await session.refresh();
toasts.push(t('workspace.updated'), 'success');
onClose();
} catch (error) {
toasts.fail(error);
} finally {
setBusy(false);
}
}
async function leave() {
if (!window.confirm(t('workspace.leaveConfirm', workspace.name))) return;
try {
await api.leaveWorkspace();
goTo('/');
} catch (error) {
toasts.fail(error);
}
}
async function destroy() {
setBusy(true);
try {
await api.deleteWorkspace(workspace.slug);
goTo('/');
} catch (error) {
toasts.fail(error);
setBusy(false);
}
}
return (
<Modal
title={t('workspace.title')}
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose} disabled={busy}>
{t('common.close')}
</button>
{isAdmin && (
<button className="btn primary" onClick={() => void save()} disabled={busy || !changed}>
{busy ? t('common.saving') : t('common.save')}
</button>
)}
</>
}
>
<div className="mb">
<label>
{t('common.name')}
<input value={name} onChange={(e) => setName(e.target.value)} disabled={!isAdmin} />
</label>
<label>
{t('workspace.address')}
<input
className="mono"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase())}
disabled={!isAdmin}
/>
<span className="hint">
{t('workspace.addressHintBefore')}
<span className="mono">/w/{slug}</span>
{t('workspace.addressHintAfter')}
</span>
</label>
<label>
{t('workspace.currency')}
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
disabled={!isAdmin}
>
{currencies.map((c) => (
<option key={c.code} value={c.code}>
{c.name} ({currencySymbol(c.code)} {c.code})
</option>
))}
</select>
<span className="hint">{t('workspace.currencyHint')}</span>
</label>
<label>
{t('workspace.language')}
<select
value={locale}
onChange={(e) => {
if (isLocale(e.target.value)) setLocale(e.target.value);
}}
disabled={!isAdmin}
>
{SUPPORTED_LOCALES.map((loc) => (
<option key={loc} value={loc}>
{LOCALE_LABELS[loc]}
</option>
))}
</select>
<span className="hint">{t('workspace.languageHint')}</span>
</label>
{isAdmin && (
<>
<h4 className="sectionhead">{t('workspace.exportHead')}</h4>
<p className="hint">{t('workspace.exportHint')}</p>
<a className="btn" href={api.exportPath()} download>
{t('workspace.download')}
</a>
</>
)}
<h4 className="sectionhead">{t('workspace.leavingHead')}</h4>
<p className="hint">{t('workspace.leavingHint')}</p>
<button className="btn" onClick={() => void leave()}>
{t('workspace.leave', workspace.name)}
</button>
{isAdmin && (
<>
<h4 className="sectionhead danger">{t('workspace.deleteHead')}</h4>
<p className="hint">
{t('workspace.deleteHintBefore')}
<span className="mono">{workspace.slug}</span>
{t('workspace.deleteHintAfter')}
</p>
<div className="rowend">
<input
className="mono"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={workspace.slug}
aria-label={t('workspace.deleteConfirmAria')}
/>
<button
className="btn danger"
onClick={() => void destroy()}
disabled={busy || confirm !== workspace.slug}
>
{t('workspace.deleteIt')}
</button>
</div>
</>
)}
</div>
</Modal>
);
}