mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
update: redefined empty state [wip].
This commit is contained in:
+540
-132
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ActionMenu,
|
||||
Button,
|
||||
Icon,
|
||||
Layout,
|
||||
Spinner,
|
||||
Spreadsheet,
|
||||
Typography,
|
||||
FloatingActionBar
|
||||
FloatingActionBar,
|
||||
Popover
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { IconCalendar, IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
@@ -14,33 +16,30 @@
|
||||
import type { Column } from '$lib/helpers/types';
|
||||
import { expandTabs } from '../table-[table]/store';
|
||||
import SpreadsheetContainer from '../table-[table]/layout/spreadsheet.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { debounce } from '$lib/helpers/debounce';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
type ColumnInput,
|
||||
mapSuggestedColumns,
|
||||
mockSuggestions,
|
||||
type SuggestedColumnSchema,
|
||||
tableColumnSuggestions
|
||||
tableColumnSuggestions,
|
||||
basicColumnOptions
|
||||
} from './store';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { sleep } from '$lib/helpers/promises';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import type { Columns } from '../table-[table]/store';
|
||||
|
||||
const {
|
||||
onColumnsFinalized = undefined
|
||||
}: {
|
||||
onColumnsFinalized?: (columns: SuggestedColumnSchema[]) => void | Promise<void>;
|
||||
} = $props();
|
||||
import { isDev } from '$lib/system';
|
||||
import { columnOptions } from '../table-[table]/columns/store';
|
||||
import Options from './options.svelte';
|
||||
import { InputSelect, InputText } from '$lib/elements/forms';
|
||||
|
||||
/**
|
||||
* flip this when you want to
|
||||
* exclude or include the `$id` for colored overlay!
|
||||
*/
|
||||
const useFirstColumnAsId = false;
|
||||
|
||||
// can also be `__select_undefined` if `$id` needs to be covered on overlay!
|
||||
const firstColumn = useFirstColumnAsId ? '$id' : '__select_undefined';
|
||||
// No props needed - we handle column creation directly
|
||||
|
||||
let resizeObserver: ResizeObserver;
|
||||
let spreadsheetContainer: HTMLElement;
|
||||
@@ -49,6 +48,11 @@
|
||||
let headerElement: HTMLElement | null = null;
|
||||
let rangeOverlayEl: HTMLDivElement | null = null;
|
||||
|
||||
let customColumns = $state([]);
|
||||
let showFloatingBar = $state(true);
|
||||
let hasTransitioned = $state(false);
|
||||
let scrollAnimationFrame: number | null = null;
|
||||
let creatingColumns = $state(false);
|
||||
const baseColProps = { draggable: false, resizable: false };
|
||||
|
||||
const findHorizontalScroller = (root: HTMLElement | null): HTMLElement | null => {
|
||||
@@ -88,79 +92,115 @@
|
||||
spreadsheetContainer.style.setProperty('--overlay-height', overlayHeight);
|
||||
};
|
||||
|
||||
// Measure first content header
|
||||
// and the cell before `actions`|`empty`
|
||||
const updateOverlayBounds = () => {
|
||||
if (!spreadsheetContainer) return;
|
||||
if (!headerElement || !headerElement.isConnected) {
|
||||
headerElement = spreadsheetContainer.querySelector('[role="rowheader"]');
|
||||
if (!headerElement) return;
|
||||
}
|
||||
if (!headerElement) return;
|
||||
|
||||
// hook the actual horizontal scroller once
|
||||
if (!hScroller || !hScroller.isConnected) {
|
||||
// TODO: @itznotabug, might not need this. check later.
|
||||
const previousScrollLeft = hScroller?.scrollLeft || 0;
|
||||
hScroller = findHorizontalScroller(headerElement);
|
||||
if (hScroller) hScroller.addEventListener('scroll', debouncedRecalc, { passive: true });
|
||||
if (hScroller) {
|
||||
hScroller.addEventListener('scroll', recalcAllThrottled, { passive: true });
|
||||
// Preserve scroll position when reconnecting to scroller
|
||||
if (previousScrollLeft > 0) {
|
||||
hScroller.scrollLeft = previousScrollLeft;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rangeOverlayEl) {
|
||||
rangeOverlayEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (customColumns.length === 0) {
|
||||
spreadsheetContainer.style.setProperty('--group-left', '40px');
|
||||
spreadsheetContainer.style.setProperty('--group-width', '100%');
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom columns mode: calculate precise overlay bounds
|
||||
const containerRect = spreadsheetContainer.getBoundingClientRect();
|
||||
const getById = (id: string) =>
|
||||
headerElement!.querySelector<HTMLElement>(
|
||||
`[role="cell"][data-header="true"][data-column-id="${id}"]`
|
||||
);
|
||||
|
||||
const firstColumnCell = getById(firstColumn);
|
||||
// const emptyCell = getById('empty');
|
||||
// const actionsCell = getById('actions');
|
||||
// Calculate visible viewport bounds
|
||||
const scrollerRect = hScroller ? hScroller.getBoundingClientRect() : containerRect;
|
||||
const visibleRight = scrollerRect.right;
|
||||
|
||||
// start = first content cell after `firstColumn`
|
||||
// (skip actions/empty if they were there)
|
||||
let startCell: HTMLElement | null = null;
|
||||
if (firstColumnCell) {
|
||||
let node = firstColumnCell.nextElementSibling as HTMLElement | null;
|
||||
while (
|
||||
node &&
|
||||
(node.dataset.columnId === 'actions' || node.dataset.columnId === 'empty')
|
||||
) {
|
||||
node = node.nextElementSibling as HTMLElement | null;
|
||||
// Get start boundary from $id column
|
||||
// as its more reliable than first custom column
|
||||
const idCell = getById('$id');
|
||||
const startCell = getById(customColumns[0]?.key);
|
||||
let endCell = getById(customColumns[customColumns.length - 1]?.key);
|
||||
|
||||
// we use a visible end column
|
||||
if (endCell) {
|
||||
const endRect = endCell.getBoundingClientRect();
|
||||
if (endRect.left >= visibleRight) {
|
||||
// Find the last column that's at least partially visible
|
||||
for (let i = customColumns.length - 2; i >= 0; i--) {
|
||||
const candidateCell = getById(customColumns[i]?.key);
|
||||
if (
|
||||
candidateCell &&
|
||||
candidateCell.getBoundingClientRect().left < visibleRight
|
||||
) {
|
||||
endCell = candidateCell;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
startCell = node;
|
||||
}
|
||||
|
||||
// end = cell right BEFORE actions;
|
||||
// else right BEFORE empty; else last content cell
|
||||
// const before = (element: HTMLElement | null) => {
|
||||
// if (!element) return null;
|
||||
// let previous = element.previousElementSibling as HTMLElement | null;
|
||||
// while (
|
||||
// previous &&
|
||||
// (previous.dataset.columnId === firstColumn ||
|
||||
// previous.getAttribute('data-select') === 'true')
|
||||
// ) {
|
||||
// previous = previous.previousElementSibling as HTMLElement | null;
|
||||
// }
|
||||
// return previous;
|
||||
// };
|
||||
|
||||
// let endCell = before(actionsCell) ?? before(emptyCell);
|
||||
// let endCell = before(emptyCell);
|
||||
|
||||
if (!startCell /* || !endCell*/) {
|
||||
if (rangeOverlayEl) rangeOverlayEl.style.display = 'none';
|
||||
if (!idCell || !startCell || !endCell) {
|
||||
if (rangeOverlayEl) {
|
||||
rangeOverlayEl.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const start = startCell.getBoundingClientRect();
|
||||
// const end = endCell.getBoundingClientRect();
|
||||
const left = Math.round(start.left - containerRect.left);
|
||||
// const width = Math.max(0, Math.round(end.right - start.right));
|
||||
// overlay positioning - start after selection column to avoid covering it
|
||||
const idRect = idCell.getBoundingClientRect();
|
||||
const selectionRect = spreadsheetContainer
|
||||
.querySelector('[data-select="true"]')
|
||||
?.getBoundingClientRect();
|
||||
|
||||
spreadsheetContainer.style.setProperty('--group-left', `${left}px`);
|
||||
// spreadsheetContainer.style.setProperty('--group-width', `${width}px`);
|
||||
// Start overlay after selection column if it exists, otherwise after $id
|
||||
let startLeft = idRect.right;
|
||||
if (selectionRect && selectionRect.right > idRect.right) {
|
||||
startLeft = selectionRect.right;
|
||||
}
|
||||
|
||||
// if (rangeOverlayEl) {
|
||||
// rangeOverlayEl.style.display = width > 0 ? 'block' : 'none';
|
||||
// }
|
||||
const left = Math.round(startLeft - containerRect.left);
|
||||
|
||||
// maximum possible width of all custom columns
|
||||
const totalFullWidth = customColumns.length * 180;
|
||||
|
||||
// get the actions column and use its left border as the boundary
|
||||
const actionsCell = headerElement!.querySelector<HTMLElement>(
|
||||
'[role="cell"][data-column-id="actions"]'
|
||||
);
|
||||
const rawVisibleWidth = Math.round(visibleRight - idRect.right);
|
||||
let maxAllowedWidth = rawVisibleWidth;
|
||||
|
||||
if (actionsCell) {
|
||||
const actionsRect = actionsCell.getBoundingClientRect();
|
||||
const actionsLeft = actionsRect.left - containerRect.left;
|
||||
maxAllowedWidth = Math.min(rawVisibleWidth, actionsLeft - left);
|
||||
}
|
||||
|
||||
// Set overlay width to not exceed actions column boundary
|
||||
const width = Math.min(totalFullWidth, maxAllowedWidth);
|
||||
|
||||
// Apply overlay positioning
|
||||
spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`);
|
||||
spreadsheetContainer.style.setProperty('--group-width', `${width + 2}px`);
|
||||
};
|
||||
|
||||
const recalcAll = () => {
|
||||
@@ -168,7 +208,39 @@
|
||||
updateOverlayBounds();
|
||||
};
|
||||
|
||||
const debouncedRecalc = debounce(recalcAll, 50);
|
||||
/**
|
||||
* Throttled version of recalcAll for scroll events to improve performance
|
||||
*/
|
||||
const recalcAllThrottled = () => {
|
||||
if (scrollAnimationFrame !== null) return;
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(() => {
|
||||
recalcAll();
|
||||
scrollAnimationFrame = null;
|
||||
});
|
||||
};
|
||||
|
||||
const customSuggestedColumns = $derived.by(() => {
|
||||
return customColumns.map((col: SuggestedColumnSchema) => {
|
||||
const columnOption = getColumnOption(col.type, col.format);
|
||||
|
||||
return {
|
||||
id: col.key,
|
||||
title: col.key,
|
||||
type: col.type as Column['type'],
|
||||
format: col.format,
|
||||
required: col.required,
|
||||
default: col.default,
|
||||
size: col.size,
|
||||
min: col.min,
|
||||
max: col.max,
|
||||
width: 180,
|
||||
icon: columnOption?.icon,
|
||||
draggable: false,
|
||||
resizable: false
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const getRowColumns = (): Column[] => [
|
||||
{
|
||||
@@ -179,39 +251,43 @@
|
||||
icon: IconFingerPrint,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: '$createdAt',
|
||||
title: '$createdAt',
|
||||
type: 'datetime',
|
||||
width: 180,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: '$updatedAt',
|
||||
title: '$updatedAt',
|
||||
type: 'datetime',
|
||||
width: 180,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
},
|
||||
...customSuggestedColumns,
|
||||
...(customColumns.length === 0
|
||||
? [
|
||||
{
|
||||
id: '$createdAt',
|
||||
title: '$createdAt',
|
||||
type: 'datetime' as Column['type'],
|
||||
width: 180,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: '$updatedAt',
|
||||
title: '$updatedAt',
|
||||
type: 'datetime' as Column['type'],
|
||||
width: 180,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'actions',
|
||||
title: '',
|
||||
type: 'string',
|
||||
icon: IconPlus,
|
||||
width: 832,
|
||||
type: 'string' as Column['type'],
|
||||
width: 40,
|
||||
isAction: true,
|
||||
...baseColProps
|
||||
},
|
||||
{ id: 'empty', title: '', type: 'string', ...baseColProps }
|
||||
}
|
||||
];
|
||||
|
||||
const spreadsheetColumns = getRowColumns();
|
||||
const spreadsheetColumns = $derived(getRowColumns());
|
||||
const emptyCells = $derived(($isSmallViewport ? 14 : 17) + (!$expandTabs ? 2 : 0));
|
||||
|
||||
onMount(async () => {
|
||||
if (spreadsheetContainer) {
|
||||
resizeObserver = new ResizeObserver(debouncedRecalc);
|
||||
resizeObserver = new ResizeObserver(recalcAll);
|
||||
resizeObserver.observe(spreadsheetContainer);
|
||||
}
|
||||
|
||||
@@ -223,23 +299,31 @@
|
||||
$tableColumnSuggestions.thinking = true;
|
||||
|
||||
try {
|
||||
const suggestedColumns = (await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.console.suggestColumns({
|
||||
databaseId: page.params.database,
|
||||
tableId: page.params.table,
|
||||
context: $tableColumnSuggestions.context ?? undefined
|
||||
})) as unknown as {
|
||||
total: number;
|
||||
columns: ColumnInput[];
|
||||
};
|
||||
await sleep(1250);
|
||||
const suggestedColumns = isDev
|
||||
? mockSuggestions
|
||||
: ((await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.console.suggestColumns({
|
||||
databaseId: page.params.database,
|
||||
tableId: page.params.table,
|
||||
context: $tableColumnSuggestions.context ?? undefined
|
||||
})) as unknown as {
|
||||
total: number;
|
||||
columns: ColumnInput[];
|
||||
});
|
||||
|
||||
trackEvent(Submit.ColumnSuggestions, {
|
||||
total: suggestedColumns.total,
|
||||
tableName: $tableColumnSuggestions.table?.name ?? undefined
|
||||
});
|
||||
|
||||
await onColumnsFinalized(mapSuggestedColumns(suggestedColumns.columns));
|
||||
customColumns = mapSuggestedColumns(suggestedColumns.columns);
|
||||
|
||||
// Set hasTransitioned to disable future animations after initial state change
|
||||
if (customColumns.length > 0) {
|
||||
setTimeout(() => (hasTransitioned = true), 300); // After transition completes
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -255,25 +339,186 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onPopoverShowStateChanged(value: boolean) {
|
||||
showFloatingBar = !value;
|
||||
const currentScrollLeft = hScroller?.scrollLeft || 0;
|
||||
|
||||
tick().then(() => {
|
||||
if (hScroller) {
|
||||
hScroller.scrollLeft = currentScrollLeft;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateColumn(columnId: string, updates: Partial<SuggestedColumnSchema>) {
|
||||
const columnIndex = customColumns.findIndex((col) => col.key === columnId);
|
||||
if (columnIndex !== -1) {
|
||||
customColumns[columnIndex] = { ...customColumns[columnIndex], ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
function getColumn(columnId: string) {
|
||||
return customColumns.find((col) => col.key === columnId);
|
||||
}
|
||||
|
||||
function getColumnOption(type: string, format?: string | null) {
|
||||
return columnOptions.find(
|
||||
(option) => option.type === type && (format ? option.format === format : !option.format)
|
||||
);
|
||||
}
|
||||
|
||||
async function createColumns() {
|
||||
creatingColumns = true;
|
||||
const client = sdk.forProject(page.params.region, page.params.project);
|
||||
|
||||
try {
|
||||
const results = [];
|
||||
|
||||
for (const column of customColumns) {
|
||||
const baseParams = {
|
||||
databaseId: page.params.database,
|
||||
tableId: page.params.table,
|
||||
key: column.key,
|
||||
required: column.required || false
|
||||
};
|
||||
|
||||
let columnResult: Columns;
|
||||
switch (column.type) {
|
||||
case 'string':
|
||||
if (column.format) {
|
||||
switch (column.format) {
|
||||
case 'email':
|
||||
columnResult =
|
||||
await client.tablesDB.createEmailColumn(baseParams);
|
||||
break;
|
||||
case 'ip':
|
||||
columnResult = await client.tablesDB.createIpColumn(baseParams);
|
||||
break;
|
||||
case 'url':
|
||||
columnResult =
|
||||
await client.tablesDB.createUrlColumn(baseParams);
|
||||
break;
|
||||
case 'enum':
|
||||
columnResult = await client.tablesDB.createEnumColumn({
|
||||
...baseParams,
|
||||
elements: []
|
||||
});
|
||||
break;
|
||||
default:
|
||||
columnResult = await client.tablesDB.createStringColumn({
|
||||
...baseParams,
|
||||
size: column.size || 255,
|
||||
xdefault: (column.default as string) || null
|
||||
});
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
columnResult = await client.tablesDB.createStringColumn({
|
||||
...baseParams,
|
||||
size: column.size || 255,
|
||||
xdefault: (column.default as string) || null
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'integer':
|
||||
columnResult = await client.tablesDB.createIntegerColumn({
|
||||
...baseParams,
|
||||
min: column.min,
|
||||
max: column.max,
|
||||
xdefault: (column.default as number) || null
|
||||
});
|
||||
break;
|
||||
|
||||
case 'double':
|
||||
columnResult = await client.tablesDB.createFloatColumn({
|
||||
...baseParams,
|
||||
min: column.min,
|
||||
max: column.max,
|
||||
xdefault: (column.default as number) || null
|
||||
});
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
columnResult = await client.tablesDB.createBooleanColumn({
|
||||
...baseParams,
|
||||
xdefault: (column.default as boolean) || null
|
||||
});
|
||||
break;
|
||||
|
||||
case 'datetime':
|
||||
columnResult = await client.tablesDB.createDatetimeColumn({
|
||||
...baseParams,
|
||||
xdefault: (column.default as string) || null
|
||||
});
|
||||
break;
|
||||
|
||||
case 'point':
|
||||
columnResult = await client.tablesDB.createPointColumn(baseParams);
|
||||
break;
|
||||
|
||||
case 'linestring':
|
||||
columnResult = await client.tablesDB.createLineColumn(baseParams);
|
||||
break;
|
||||
|
||||
case 'polygon':
|
||||
columnResult = await client.tablesDB.createPolygonColumn(baseParams);
|
||||
break;
|
||||
}
|
||||
|
||||
results.push(columnResult);
|
||||
}
|
||||
|
||||
await invalidate(Dependencies.TABLE);
|
||||
|
||||
// Reset state
|
||||
customColumns = [];
|
||||
$tableColumnSuggestions.context = null;
|
||||
$tableColumnSuggestions.enabled = false;
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Columns created successfully'
|
||||
});
|
||||
|
||||
trackEvent(Submit.ColumnCreate, { type: 'suggestions' });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.ColumnCreate);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
creatingColumns = false;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
resizeObserver?.disconnect();
|
||||
hScroller?.removeEventListener('scroll', debouncedRecalc);
|
||||
hScroller?.removeEventListener('scroll', recalcAllThrottled);
|
||||
if (scrollAnimationFrame) {
|
||||
cancelAnimationFrame(scrollAnimationFrame);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:resize={debouncedRecalc} on:scroll={debouncedRecalc} />
|
||||
<svelte:window on:resize={recalcAll} on:scroll={recalcAll} />
|
||||
|
||||
<div bind:this={spreadsheetContainer} class="databases-spreadsheet spreadsheet-container-outer">
|
||||
<div
|
||||
bind:this={spreadsheetContainer}
|
||||
class="databases-spreadsheet spreadsheet-container-outer"
|
||||
class:custom-columns={customColumns.length > 0}>
|
||||
<div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
bind:this={rangeOverlayEl}
|
||||
class="columns-range-overlay"
|
||||
class:thinking={$tableColumnSuggestions.thinking}>
|
||||
class:thinking={$tableColumnSuggestions.thinking}
|
||||
class:no-transition={hasTransitioned && customColumns.length > 0}>
|
||||
</div>
|
||||
|
||||
{#if $tableColumnSuggestions.thinking}
|
||||
<div class="floating-action-wrapper">
|
||||
<div class="floating-action-wrapper thinking">
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Layout.Stack direction="row" gap="xxs" alignItems="center">
|
||||
@@ -297,6 +542,41 @@
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
{:else if customColumns.length > 0 && showFloatingBar}
|
||||
<div class="floating-action-wrapper expanded">
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
{#if creatingColumns}
|
||||
<Spinner size="s" />
|
||||
{/if}
|
||||
|
||||
<Typography.Text style="white-space: nowrap">
|
||||
{creatingColumns
|
||||
? 'Creating columns...'
|
||||
: 'Review and edit suggested columns before applying'}
|
||||
</Typography.Text>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
{#if !creatingColumns}
|
||||
<Layout.Stack direction="row" gap="xs" alignItems="center">
|
||||
<Button.Button
|
||||
size="xs"
|
||||
variant="text"
|
||||
on:click={() => {
|
||||
customColumns = [];
|
||||
$tableColumnSuggestions.context = null;
|
||||
$tableColumnSuggestions.enabled = false;
|
||||
}}
|
||||
>Dismiss
|
||||
</Button.Button>
|
||||
<Button.Button size="xs" variant="primary" on:click={createColumns}
|
||||
>Apply
|
||||
</Button.Button>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -308,28 +588,130 @@
|
||||
columns={spreadsheetColumns}
|
||||
bottomActionClick={() => {}}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
{#each spreadsheetColumns as column (column.id)}
|
||||
<Spreadsheet.Header.Cell
|
||||
{root}
|
||||
column={column.id}
|
||||
icon={column.icon ?? undefined}>
|
||||
{#if column.isAction}
|
||||
{#each spreadsheetColumns as column, index (index)}
|
||||
{#if column.isAction}
|
||||
<Spreadsheet.Header.Cell column="actions" {root}>
|
||||
<Button.Button icon variant="extra-compact">
|
||||
<Icon icon={IconPlus} color="--fgcolor-neutral-primary" />
|
||||
</Button.Button>
|
||||
{:else if column.id === 'actions' || column.id === 'empty'}
|
||||
{column.title}
|
||||
{:else}
|
||||
<Layout.Stack
|
||||
gap="xs"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center">
|
||||
{column.title}
|
||||
<SortButton disabled column={column.id} />
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Spreadsheet.Header.Cell>
|
||||
</Spreadsheet.Header.Cell>
|
||||
{:else}
|
||||
<Options onShowStateChanged={onPopoverShowStateChanged}>
|
||||
{#snippet children(toggle)}
|
||||
<Spreadsheet.Header.Cell
|
||||
{root}
|
||||
column={column.id}
|
||||
on:contextmenu={toggle}>
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
justifyContent="space-between">
|
||||
<Layout.Stack
|
||||
gap="xs"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center">
|
||||
{column.title}
|
||||
|
||||
<SortButton disabled column={column.id} />
|
||||
</Layout.Stack>
|
||||
|
||||
<Popover let:toggle portal>
|
||||
<Button.Button
|
||||
size="xs"
|
||||
variant="extra-compact"
|
||||
on:click={toggle}>
|
||||
<Icon
|
||||
size="s"
|
||||
color="--fgcolor-neutral-weak"
|
||||
icon={column.icon ?? undefined} />
|
||||
</Button.Button>
|
||||
|
||||
<div
|
||||
let:toggle
|
||||
slot="tooltip"
|
||||
class="actions-menu-wrapper"
|
||||
style:max-height="184px">
|
||||
<ActionMenu.Root maxWidth="232px">
|
||||
{#each basicColumnOptions as option}
|
||||
<ActionMenu.Item.Button
|
||||
on:click={() => {
|
||||
toggle();
|
||||
updateColumn(column.id, {
|
||||
type: option.type,
|
||||
format: option.format || null
|
||||
});
|
||||
}}>
|
||||
<Layout.Stack
|
||||
gap="s"
|
||||
direction="row"
|
||||
alignContent="center">
|
||||
<Icon icon={option.icon} />
|
||||
{option.name}
|
||||
</Layout.Stack>
|
||||
</ActionMenu.Item.Button>
|
||||
{/each}
|
||||
</ActionMenu.Root>
|
||||
</div>
|
||||
</Popover>
|
||||
</Layout.Stack>
|
||||
</Spreadsheet.Header.Cell>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tooltipChildren()}
|
||||
{@const columnObj = getColumn(column.id)}
|
||||
{#if columnObj}
|
||||
{@const selectedOption = getColumnOption(
|
||||
columnObj.type,
|
||||
columnObj.format
|
||||
)}
|
||||
{@const ColumnComponent = selectedOption?.component}
|
||||
<Layout.Stack gap="xl">
|
||||
<Layout.Stack gap="xl">
|
||||
<Layout.Stack direction="row">
|
||||
<InputText
|
||||
id="key"
|
||||
label="Key"
|
||||
placeholder="Enter key"
|
||||
bind:value={columnObj.key}
|
||||
autofocus
|
||||
required
|
||||
pattern="^[A-Za-z0-9][A-Za-z0-9._\-]*$" />
|
||||
|
||||
<InputSelect
|
||||
id="type"
|
||||
required
|
||||
label="Type"
|
||||
value={selectedOption?.name || 'String'}
|
||||
on:change={(e) => {
|
||||
const newOption = columnOptions.find(
|
||||
(opt) => opt.name === e.detail
|
||||
);
|
||||
if (newOption) {
|
||||
updateColumn(column.id, {
|
||||
type: newOption.type,
|
||||
format: newOption.format || null
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={basicColumnOptions.map((col) => {
|
||||
return {
|
||||
label: col.name,
|
||||
value: col.name,
|
||||
leadingIcon: col.icon
|
||||
};
|
||||
})} />
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{#if ColumnComponent}
|
||||
<ColumnComponent data={columnObj} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Options>
|
||||
{/if}
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
</Spreadsheet.Root>
|
||||
@@ -338,7 +720,7 @@
|
||||
<div
|
||||
class="spreadsheet-fade-bottom"
|
||||
data-collapsed-tabs={!$expandTabs}
|
||||
style="height: var(--overlay-height)">
|
||||
style="height: var(--overlay-height);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -347,27 +729,30 @@
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
scrollbar-width: none;
|
||||
|
||||
&.custom-columns {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
& :global([role='rowheader'] :nth-last-child(2) [role='presentation']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.columns-range-overlay {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
margin-block-start: 2px;
|
||||
left: var(--group-left, 0px);
|
||||
height: calc(var(--overlay-height, 60vh) + 36px);
|
||||
border-radius: var(--border-radius-S, 8px);
|
||||
width: var(--group-width, 100%);
|
||||
height: 100%;
|
||||
background: color-mix(in oklab, #fd366e 7%, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 21;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
/* border + inner hairline without double-counting */
|
||||
box-shadow:
|
||||
0 0 0 var(--border-width-L, 2px) #fd366e,
|
||||
inset 0 0 0 1px color-mix(in oklab, #fe9567 20%, transparent);
|
||||
&.no-transition {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* pretty gradient wash (with fallback) */
|
||||
background: rgba(253, 54, 110, 0.07);
|
||||
@@ -380,6 +765,15 @@
|
||||
}
|
||||
|
||||
&.thinking {
|
||||
margin-block-start: 2px;
|
||||
height: calc(100% - 4px);
|
||||
border-radius: var(--border-radius-S, 8px);
|
||||
box-shadow:
|
||||
0 0 0 var(--border-width-l, 2px) #fd366e,
|
||||
inset 0 0 0 1px color-mix(in oklab, #fe9567 20%, transparent);
|
||||
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -405,15 +799,28 @@
|
||||
); /* change this value if the firstColumn is changed for overlay logic.*/
|
||||
}
|
||||
|
||||
& .floating-action-wrapper.expanded :global(:first-child) {
|
||||
z-index: 21;
|
||||
left: calc(50% - 80px);
|
||||
max-width: 500px !important;
|
||||
}
|
||||
|
||||
& :global(.spreadsheet-container) {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
& :global([data-select='true']) {
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* alternative selector for header selection */
|
||||
& :global(.sticky-header [data-select='true']) {
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spreadsheet-fade-bottom {
|
||||
@@ -430,6 +837,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.theme-dark) .spreadsheet-fade-bottom {
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Popover } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
tooltipChildren,
|
||||
onShowStateChanged = null
|
||||
}: {
|
||||
children: Snippet<[toggle: (event: Event) => void]>;
|
||||
tooltipChildren: Snippet<[toggle: (event: Event) => void]>;
|
||||
onShowStateChanged?: (showing: boolean) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Popover let:toggle let:showing placement="bottom-start" portal>
|
||||
{onShowStateChanged?.(showing)}
|
||||
|
||||
{@render children(toggle)}
|
||||
|
||||
<div slot="tooltip" let:toggle style:width="480px" style:padding="16px">
|
||||
{@render tooltipChildren(toggle)}
|
||||
</div>
|
||||
</Popover>
|
||||
+70
@@ -1,4 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { columnOptions } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/store';
|
||||
|
||||
export type TableColumnSuggestions = {
|
||||
enabled: boolean;
|
||||
@@ -30,6 +31,73 @@ export const tableColumnSuggestions = writable<TableColumnSuggestions>({
|
||||
table: null
|
||||
});
|
||||
|
||||
// TODO: @itznotabug, remove later, this is MOCK DATA ONLY.
|
||||
export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
|
||||
total: 7,
|
||||
columns: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
size: 255,
|
||||
format: null,
|
||||
required: true,
|
||||
formatOptions: null
|
||||
},
|
||||
{
|
||||
name: 'authorName',
|
||||
type: 'string',
|
||||
size: 128,
|
||||
format: null,
|
||||
required: true,
|
||||
formatOptions: null
|
||||
},
|
||||
{
|
||||
name: 'publishedYear',
|
||||
type: 'integer',
|
||||
size: null,
|
||||
format: null,
|
||||
required: true,
|
||||
formatOptions: {
|
||||
min: 1500,
|
||||
max: null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'genre',
|
||||
type: 'string',
|
||||
size: 64,
|
||||
format: null,
|
||||
required: false,
|
||||
formatOptions: null,
|
||||
default: null
|
||||
},
|
||||
{
|
||||
name: 'isbn',
|
||||
type: 'string',
|
||||
size: 13,
|
||||
required: false,
|
||||
formatOptions: null,
|
||||
default: null
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
size: 32,
|
||||
format: null,
|
||||
required: false,
|
||||
formatOptions: null,
|
||||
default: null
|
||||
},
|
||||
{
|
||||
name: 'pageCount',
|
||||
type: 'integer',
|
||||
required: false,
|
||||
min: 1,
|
||||
max: 10000,
|
||||
default: null
|
||||
}
|
||||
]
|
||||
};
|
||||
export type ColumnInput = {
|
||||
name: string;
|
||||
type: string;
|
||||
@@ -63,3 +131,5 @@ export function mapSuggestedColumns<T extends ColumnInput>(columns: T[]): Sugges
|
||||
format: col.format ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export const basicColumnOptions = columnOptions.slice(0, -1);
|
||||
|
||||
+1
-5
@@ -225,11 +225,7 @@
|
||||
}} />
|
||||
{/if}
|
||||
{:else if $tableColumnSuggestions.enabled && $tableColumnSuggestions.table && $tableColumnSuggestions.table.id === page.params.table}
|
||||
<SuggestionsEmptySheet
|
||||
onColumnsFinalized={(columns) => {
|
||||
showSuggestionsModal = true;
|
||||
columnSuggestionsSchema = columns;
|
||||
}} />
|
||||
<SuggestionsEmptySheet />
|
||||
{:else}
|
||||
<EmptySheet
|
||||
mode="rows"
|
||||
|
||||
Reference in New Issue
Block a user