Export multiple workspaces

This commit is contained in:
Gregory Schier
2024-03-19 13:43:33 -07:00
parent bb561d7b98
commit 351cfae042
10 changed files with 224 additions and 61 deletions

View File

@@ -0,0 +1,107 @@
import { invoke } from '@tauri-apps/api';
import { save } from '@tauri-apps/api/dialog';
import { useState } from 'react';
import slugify from 'slugify';
import type { Workspace } from '../lib/models';
import { count } from '../lib/pluralize';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { HStack, VStack } from './core/Stacks';
interface Props {
onHide: () => void;
activeWorkspace: Workspace;
workspaces: Workspace[];
}
export function ExportDataDialog({ onHide, activeWorkspace, workspaces: allWorkspaces }: Props) {
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
[activeWorkspace.id]: true,
});
const workspaces = [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)];
const handleToggleAll = () => {
setSelectedWorkspaces(
allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),
);
};
const handleExport = async () => {
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : 'workspaces';
const exportPath = await save({
title: 'Export Data',
defaultPath: `yaak.${slug}.json`,
});
if (exportPath == null) {
return;
}
await invoke('cmd_export_data', { workspaceIds: ids, exportPath });
onHide();
};
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<VStack space={3} className="w-full mb-3 px-4">
<table className="w-full mb-auto min-w-full max-w-full divide-y">
<thead>
<tr onClick={handleToggleAll}>
<th className="w-6 min-w-0 py-2 text-left pl-1">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && !noneSelected}
hideLabel
title="All workspaces"
onChange={handleToggleAll}
/>
</th>
<th className="py-2 text-left pl-4">Workspace</th>
</tr>
</thead>
<tbody className="divide-y">
{workspaces.map((w) => (
<tr
key={w.id}
onClick={() => {
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }));
}}
>
<td className="min-w-0 py-1 pl-1">
<Checkbox
checked={selectedWorkspaces[w.id] ?? false}
title={w.name}
hideLabel
onChange={() => {
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }));
}}
/>
</td>
<td className="py-1 pl-4 text-gray-700 whitespace-nowrap overflow-x-auto hide-scrollbars">
{w.name} {w.id === activeWorkspace.id ? '(current workspace)' : ''}
</td>
</tr>
))}
</tbody>
</table>
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={onHide}>
Cancel
</Button>
<Button
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={handleExport}
>
Export {count('Workspace', numSelected, { omitSingle: true, noneWord: 'Nothing' })}
</Button>
</HStack>
</VStack>
);
}

View File

@@ -60,17 +60,27 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
// Solids
variant === 'solid' && color === 'custom' && 'ring-blue-400',
variant === 'solid' &&
color === 'custom' &&
'ring-blue-400 enabled:hocus:bg-highlightSecondary',
variant === 'solid' &&
color === 'default' &&
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-800 ring-blue-400',
variant === 'solid' &&
color === 'gray' &&
'text-gray-800 bg-highlight enabled:hocus:text-gray-1000 ring-blue-400',
variant === 'solid' && color === 'primary' && 'bg-blue-400 text-white ring-blue-700',
variant === 'solid' && color === 'secondary' && 'bg-violet-400 text-white ring-violet-700',
variant === 'solid' && color === 'warning' && 'bg-orange-400 text-white ring-orange-700',
variant === 'solid' && color === 'danger' && 'bg-red-400 text-white ring-red-700',
'text-gray-800 bg-gray-200/70 enabled:hocus:bg-gray-200 ring-blue-400',
variant === 'solid' &&
color === 'primary' &&
'bg-blue-400 text-white ring-blue-700 enabled:hocus:bg-blue-500',
variant === 'solid' &&
color === 'secondary' &&
'bg-violet-400 text-white ring-violet-700 enabled:hocus:bg-violet-500',
variant === 'solid' &&
color === 'warning' &&
'bg-orange-400 text-white ring-orange-700 enabled:hocus:bg-orange-500',
variant === 'solid' &&
color === 'danger' &&
'bg-red-400 text-white ring-red-700 enabled:hocus:bg-red-500',
// Borders
variant === 'border' && 'border',
variant === 'border' &&

View File

@@ -8,10 +8,21 @@ interface Props {
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
inputWrapperClassName?: string;
indeterminate?: boolean;
hideLabel?: boolean;
}
export function Checkbox({ checked, onChange, className, disabled, title, hideLabel }: Props) {
export function Checkbox({
checked,
indeterminate,
onChange,
className,
inputWrapperClassName,
disabled,
title,
hideLabel,
}: Props) {
return (
<HStack
as="label"
@@ -19,16 +30,19 @@ export function Checkbox({ checked, onChange, className, disabled, title, hideLa
alignItems="center"
className={classNames(className, 'text-gray-900 text-sm', disabled && 'opacity-disabled')}
>
<div className="relative flex">
<div className={classNames(inputWrapperClassName, 'relative flex')}>
<input
aria-hidden
className="appearance-none w-4 h-4 flex-shrink-0 border border-gray-200 rounded focus:border-focus outline-none ring-0"
className={classNames(
'opacity-50 appearance-none w-4 h-4 flex-shrink-0 border border-[currentColor]',
'rounded focus:border-focus focus:opacity-100 outline-none ring-0',
)}
type="checkbox"
disabled={disabled}
onChange={() => onChange(!checked)}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
<Icon size="sm" icon={indeterminate ? 'minus' : checked ? 'check' : 'empty'} />
</div>
</div>
{/*<button*/}

View File

@@ -17,6 +17,7 @@ const icons = {
arrowUpFromDot: lucide.ArrowUpFromDotIcon,
box: lucide.BoxIcon,
cake: lucide.CakeIcon,
minus: lucide.MinusIcon,
chat: lucide.MessageSquare,
check: lucide.CheckIcon,
chevronDown: lucide.ChevronDownIcon,

View File

@@ -43,8 +43,8 @@ export function Prompt({
>
<Input
hideLabel
require={require}
autoSelect
require={require}
placeholder={placeholder}
label={label}
name={name}

View File

@@ -1,31 +1,36 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { save } from '@tauri-apps/api/dialog';
import slugify from 'slugify';
import { useDialog } from '../components/DialogContext';
import { ExportDataDialog } from '../components/ExportDataDialog';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAlert } from './useAlert';
import { useWorkspaces } from './useWorkspaces';
export function useExportData() {
const workspace = useActiveWorkspace();
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const alert = useAlert();
const dialog = useDialog();
return useMutation({
onError: (err: string) => {
alert({ id: 'export-failed', title: 'Export Failed', body: err });
},
mutationFn: async () => {
if (workspace == null) return;
if (activeWorkspace == null || workspaces.length === 0) return;
const workspaceSlug = slugify(workspace.name, { lower: true });
const exportPath = await save({
title: 'Export Data',
defaultPath: `yaak.${workspaceSlug}.json`,
dialog.show({
id: 'export-data',
title: 'Export App Data',
size: 'md',
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
workspaces={workspaces}
activeWorkspace={activeWorkspace}
/>
),
});
if (exportPath == null) {
return;
}
await invoke('cmd_export_data', { workspaceId: workspace.id, exportPath });
},
});
}

View File

@@ -5,6 +5,16 @@ export function pluralize(word: string, count: number): string {
return `${word}s`;
}
export function count(word: string, count: number): string {
export function count(
word: string,
count: number,
opt: { omitSingle?: boolean; noneWord?: string } = {},
): string {
if (opt.omitSingle && count === 1) {
return word;
}
if (opt.noneWord && count === 0) {
return opt.noneWord;
}
return `${count} ${pluralize(word, count)}`;
}

View File

@@ -9,10 +9,7 @@
@apply w-full h-full overflow-hidden text-gray-900 bg-gray-50;
}
/* Setup default transitions for elements */
* {
transition: background-color var(--transition-duration), border-color var(--transition-duration),
box-shadow var(--transition-duration);
font-variant-ligatures: none;
}