mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-18 07:23:51 +01:00
Export multiple workspaces
This commit is contained in:
107
src-web/components/ExportDataDialog.tsx
Normal file
107
src-web/components/ExportDataDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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*/}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,8 +43,8 @@ export function Prompt({
|
||||
>
|
||||
<Input
|
||||
hideLabel
|
||||
require={require}
|
||||
autoSelect
|
||||
require={require}
|
||||
placeholder={placeholder}
|
||||
label={label}
|
||||
name={name}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user