Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

6.5 kB Ruw bestand
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>
  );
}