feat: inline cell edits for arrays with options for advance edits.

fix: top spacing on mobile for side-sheets, make em default instead of passing a prop, which can be missed sometimes.
fix: issue with non-persistent expanded tab after toggle.
This commit is contained in:
Darshan
2025-08-18 10:54:27 +05:30
parent d5805c353b
commit 1a359fde70
15 changed files with 321 additions and 141 deletions
@@ -51,4 +51,5 @@
on:input
bind:value>
<slot name="info" slot="info" />
<slot name="end" slot="end" />
</Input.Textarea>
@@ -33,7 +33,6 @@
spreadsheetLoading,
rowActivitySheet,
spreadsheetRenderKey,
columnsWidth,
expandTabs
} from './store';
import { addSubPanel, registerCommands, updateCommandGroupRanks } from '$lib/commandCenter';
@@ -244,14 +243,10 @@
$spreadsheetLoading = true;
$randomDataModalState.show = false;
let attributes = $table.columns;
if (!attributes.length) {
let columns = $table.columns;
if (!columns.length) {
try {
attributes = await generateColumns(
$project,
page.params.database,
page.params.table
);
columns = await generateColumns($project, page.params.database, page.params.table);
await invalidate(Dependencies.TABLE);
} catch (e) {
@@ -264,12 +259,12 @@
}
}
/* let the attributes be processed! */
/* let the columns be processed! */
await sleep(1250);
let rowIds = [];
try {
const { rows, ids } = generateFakeRecords(attributes, $randomDataModalState.value);
const { rows, ids } = generateFakeRecords(columns, $randomDataModalState.value);
rowIds = ids;
@@ -311,7 +306,6 @@
<slot />
<SideSheet
spaced
closeOnBlur
title={$showCreateAttributeSheet.title}
bind:show={$showCreateAttributeSheet.show}
@@ -337,7 +331,6 @@
</SideSheet>
<SideSheet
spaced
closeOnBlur
title={$databaseColumnSheetOptions.title}
bind:show={$databaseColumnSheetOptions.show}
@@ -353,7 +346,6 @@
</SideSheet>
<SideSheet
spaced
closeOnBlur
title={$databaseRowSheetOptions.title}
bind:show={$databaseRowSheetOptions.show}
@@ -366,7 +358,6 @@
</SideSheet>
<SideSheet
spaced
closeOnBlur
title="Create index"
bind:show={$showCreateIndexSheet.show}
@@ -382,7 +373,7 @@
externalColumnKey={$showCreateIndexSheet.column} />
</SideSheet>
<SideSheet spaced title="Row activity" bind:show={$rowActivitySheet.show} closeOnBlur>
<SideSheet title="Row activity" bind:show={$rowActivitySheet.show} closeOnBlur>
<RowActivity />
</SideSheet>
@@ -138,7 +138,10 @@
icon
secondary
disabled={!(hasColumns && hasValidColumns)}
on:click={() => ($expandTabs = !$expandTabs)}>
on:click={() => {
$expandTabs = !$expandTabs;
preferences.setTableHeaderExpanded($table.$id, $expandTabs);
}}>
<Icon icon={!$expandTabs ? IconChevronDown : IconChevronUp} size="s" />
</Button>
{/if}
@@ -128,8 +128,8 @@
{#if selectedColumn?.type !== 'relationship'}
<InputText
id="key"
label="Column Key"
placeholder="Enter Key"
label="Column key"
placeholder="Enter key"
bind:value={selectedColumn.key}
autofocus />
{/if}
@@ -153,8 +153,8 @@
{#if selectedColumn?.type !== 'relationship'}
<InputText
id="key"
label="Attribute Key"
placeholder="Enter Key"
label="Column key"
placeholder="Enter key"
bind:value={selectedColumn.key}
autofocus />
{/if}
@@ -105,47 +105,43 @@
</script>
{#if $createRow}
<div class="sheet-container">
<!-- TODO: add a ID badge-->
<SideSheet
spaced
title={`${existingData ? 'Duplicate' : 'Create'} row`}
bind:show={showSheet}
closeOnBlur={false}
submit={{
text: 'Create',
disabled: isSubmitting,
onClick: () => create()
}}>
<Layout.Stack gap="xxl">
<div bind:this={columnFormWrapper}>
<ColumnForm
columns={$createRow.columns}
bind:customId={$createRow.id}
bind:formValues={$createRow.row} />
</div>
<SideSheet
title={`${existingData ? 'Duplicate' : 'Create'} row`}
bind:show={showSheet}
closeOnBlur={false}
submit={{
text: 'Create',
disabled: isSubmitting,
onClick: () => create()
}}>
<Layout.Stack gap="xxl">
<div bind:this={columnFormWrapper}>
<ColumnForm
columns={$createRow.columns}
bind:customId={$createRow.id}
bind:formValues={$createRow.row} />
</div>
<Layout.Stack gap="xl">
<Typography.Text>
Choose which permission scopes to grant your application. It is best
practice to allow only the permissions you need to meet your project goals.
</Typography.Text>
{#if table.rowSecurity}
<Alert.Inline status="info">
<svelte:fragment slot="title">Row security is enabled</svelte:fragment>
Users will be able to access this row if they have been granted
<b>either row or table permissions</b>.
</Alert.Inline>
<Permissions bind:permissions={$createRow.permissions} />
{:else}
<Alert.Inline status="info">
<svelte:fragment slot="title">Row security is disabled</svelte:fragment>
If you want to assign row permissions, navigate to Table settings and enable
row security. Otherwise, only table permissions will be used.
</Alert.Inline>
{/if}
</Layout.Stack>
<Layout.Stack gap="xl">
<Typography.Text>
Choose which permission scopes to grant your application. It is best practice to
allow only the permissions you need to meet your project goals.
</Typography.Text>
{#if table.rowSecurity}
<Alert.Inline status="info">
<svelte:fragment slot="title">Row security is enabled</svelte:fragment>
Users will be able to access this row if they have been granted
<b>either row or table permissions</b>.
</Alert.Inline>
<Permissions bind:permissions={$createRow.permissions} />
{:else}
<Alert.Inline status="info">
<svelte:fragment slot="title">Row security is disabled</svelte:fragment>
If you want to assign row permissions, navigate to Table settings and enable
row security. Otherwise, only table permissions will be used.
</Alert.Inline>
{/if}
</Layout.Stack>
</SideSheet>
</div>
</Layout.Stack>
</SideSheet>
{/if}
@@ -1,23 +1,28 @@
<script lang="ts">
import deepEqual from 'deep-equal';
import type { Columns } from './store';
import { onDestroy, onMount } from 'svelte';
import type { Models } from '@appwrite.io/console';
import ColumnItem from './row-[row]/columnItem.svelte';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
let {
column,
row = $bindable(null),
onChange = null,
onRevert = null,
openSideSheet = null,
onRowStructureUpdate = null
}: {
row: Models.Row;
column: Columns;
openSideSheet?: () => void;
onChange?: (row: Models.DefaultRow) => void;
onRevert?: (row: Models.DefaultRow) => void;
onRowStructureUpdate?: (row: Models.Row) => Promise<boolean>;
} = $props();
let original: Models.Row;
let wrapperEl: HTMLDivElement;
const dispatch = createEventDispatcher();
onMount(() => {
original = structuredClone(row);
@@ -38,18 +43,24 @@
const accepted = await onRowStructureUpdate(row);
if (!accepted) {
row = original;
dispatch('revert', original);
onRevert?.(original as Models.DefaultRow);
}
}
});
$effect(() => {
if (!deepEqual(original, row)) {
dispatch('change', row);
onChange?.(row as Models.DefaultRow);
}
});
</script>
<div style:width="100%" bind:this={wrapperEl}>
<ColumnItem {column} fromSpreadsheet bind:formValues={row} label={undefined} editing />
<ColumnItem
{column}
editing
fromSpreadsheet
label={undefined}
bind:formValues={row}
on:click={openSideSheet} />
</div>
@@ -8,6 +8,7 @@
import { canWriteTables } from '$lib/stores/roles';
import { expandTabs, table } from './store';
import { Layout } from '@appwrite.io/pink-svelte';
import { preferences } from '$lib/stores/preferences';
const databaseId = $derived(page.params.database);
@@ -65,6 +66,13 @@
const isSpreadsheetPage = endings.some((end) => page.route.id?.endsWith(end));
return !isSpreadsheetPage;
});
$effect(() => {
if (nonSheetPages) expandTabs.set(true);
else {
expandTabs.set(preferences.isTableHeaderExpanded(tableId));
}
});
</script>
<div class:nonSheetPages>
@@ -6,14 +6,12 @@
let {
show = $bindable(false),
title,
spaced = false,
closeOnBlur = false,
submit,
children = null
}: {
show: boolean;
title: string;
spaced?: boolean;
closeOnBlur?: boolean;
submit?:
| {
@@ -26,7 +24,7 @@
} = $props();
</script>
<div class="sheet-container" class:spaced>
<div class="sheet-container">
<Sheet bind:open={show} {closeOnBlur}>
<div slot="header" style:width="100%">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
@@ -79,7 +77,7 @@
position: absolute;
@media (max-width: 768px) {
&.spaced :global(aside header) {
& :global(aside header) {
margin-top: 6rem;
}
}
@@ -10,6 +10,7 @@
export let id: string;
export let label: string;
export let array: boolean | undefined = undefined;
export let optionalText: string | undefined = undefined;
export let value: string | number | boolean | null | string[];
export let editing = false;
@@ -50,17 +51,21 @@
{label}
{column}
{limited}
{array}
{optionalText}
bind:value />
{:else}
<!-- the `on:click` is from string > array mode for advanced edit button -->
<svelte:component
this={columnsTypeMap[column.type]}
this={column.array ? columnsTypeMap['string'] : columnsTypeMap[column.type]}
{id}
{editing}
{limited}
{label}
{column}
{array}
{optionalText}
on:click
bind:value />
{/if}
{/if}
@@ -6,11 +6,19 @@
import Column from './column.svelte';
import type { Columns } from '../store';
export let column: Columns;
export let formValues: object = {};
export let label: string;
export let editing = false;
export let fromSpreadsheet: boolean = false;
let {
column,
formValues = $bindable({}),
label,
editing = false,
fromSpreadsheet = false
}: {
column: Columns;
formValues: object;
label: string;
editing?: boolean;
fromSpreadsheet?: boolean;
} = $props();
function removeArrayItem(key: string, index: number) {
formValues = {
@@ -48,18 +56,41 @@
{#if column.array}
{#if formValues[column.key]?.length === 0}
<Layout.Stack direction="row" alignContent="space-between">
<Layout.Stack gap="xxs" direction="row" alignItems="center">
<Typography.Text variant="m-500">{label}</Typography.Text>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
{getColumnType(column)}
</Typography.Text>
{#if fromSpreadsheet}
<Column
array
{label}
{column}
{editing}
id={column.key}
limited={fromSpreadsheet}
optionalText={getColumnType(column)}
bind:value={formValues[column.key]}
on:click />
{:else}
<Layout.Stack direction="row" alignContent="space-between">
<Layout.Stack gap="xxs" direction="row" alignItems="center">
<Typography.Text variant="m-500">{label}</Typography.Text>
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary">
{getColumnType(column)}
</Typography.Text>
</Layout.Stack>
<Button secondary on:click={() => addArrayItem(column.key)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add item
</Button>
</Layout.Stack>
<Button secondary on:click={() => addArrayItem(column.key)}>
<Icon icon={IconPlus} slot="start" size="s" />
Add item
</Button>
</Layout.Stack>
{/if}
{:else if fromSpreadsheet}
<Column
array
{label}
{column}
{editing}
id={column.key}
limited={fromSpreadsheet}
optionalText={getColumnType(column)}
bind:value={formValues[column.key]} />
{:else}
<Layout.Stack>
{#each [...(formValues[column.key]?.keys() ?? [])] as index}
@@ -89,8 +120,8 @@
{label}
{editing}
{column}
limited={fromSpreadsheet}
id={column.key}
limited={fromSpreadsheet}
optionalText={getColumnType(column)}
bind:value={formValues[column.key]} />
{/if}
@@ -1,38 +1,152 @@
<script lang="ts">
import { InputText, InputTextarea } from '$lib/elements/forms';
import type { Models } from '@appwrite.io/console';
import { Layout, Link } from '@appwrite.io/pink-svelte';
import { InputText, InputTextarea } from '$lib/elements/forms';
export let id: string;
export let label: string;
export let value: string;
export let limited: boolean = false;
export let column: Models.ColumnString;
let {
id,
label,
value = $bindable(),
array = false,
limited = false,
column
}: {
id: string;
label: string;
value: string | number | boolean | string[] | number[] | boolean[] | null;
array?: boolean;
limited?: boolean;
column:
| Models.ColumnString
| Models.ColumnInteger
| Models.ColumnFloat
| Models.ColumnBoolean;
} = $props();
$: autofocus = limited;
$: maxlength = limited ? undefined : column.size;
const autofocus = $derived(limited);
const maxlength = $derived(
limited
? undefined
: column.type === 'string'
? (column as Models.ColumnString).size
: undefined
);
// TODO: do we need the nullable checkbox?
$: nullable = !limited ? !column.required : false;
const nullable = $derived(!limited ? !column.required : false);
const columnSize = $derived('size' in column ? column.size : 0);
let stringValue = $state('');
function parseValue(str: string): number | boolean | string | null {
const trimmed = str.trim();
if (!trimmed) return null;
switch (column.type) {
case 'integer': {
const int = parseInt(trimmed, 10);
return isNaN(int) ? null : int;
}
case 'double': {
const float = parseFloat(trimmed);
return isNaN(float) ? null : float;
}
case 'boolean': {
const lower = trimmed.toLowerCase();
if (lower === 'true' || lower === '1') return true;
if (lower === 'false' || lower === '0') return false;
return null;
}
case 'string':
default:
return trimmed;
}
}
$effect(() => {
if (array && Array.isArray(value)) {
stringValue = value.map(String).join(', ');
} else if (value !== null && value !== undefined) {
stringValue = String(value);
} else {
stringValue = '';
}
});
$effect(() => {
if (array) {
const newArray = stringValue
.split(',')
.map((item) => parseValue(item))
.filter((item) => item !== null);
if (JSON.stringify(newArray) !== JSON.stringify(value)) {
value = newArray as string[] | number[] | boolean[];
}
} else {
const parsedValue = parseValue(stringValue);
if (parsedValue !== value) {
value = parsedValue;
}
}
});
const getPlaceholder = () => {
if (!array) {
switch (column.type) {
case 'integer':
return 'Enter integer';
case 'double':
return 'Enter number';
case 'boolean':
return 'Enter true or false';
case 'string':
default:
return 'Enter string';
}
} else {
switch (column.type) {
case 'integer':
return 'Enter integers separated by commas';
case 'double':
return 'Enter numbers separated by commas';
case 'boolean':
return 'Enter true/false separated by commas';
case 'string':
default:
return 'Enter strings separated by commas';
}
}
};
</script>
{#if column.size >= 50}
{#if columnSize >= 50 || array}
<InputTextarea
{id}
{label}
bind:value
{nullable}
{maxlength}
{autofocus}
placeholder="Enter string"
bind:value={stringValue}
required={column.required}
{nullable} />
placeholder={getPlaceholder()}>
<Layout.Stack direction="column" alignItems="flex-start" slot="end">
{#if array}
<Link.Button on:click size="s" variant="quiet">Advanced edit</Link.Button>
{/if}
</Layout.Stack>
</InputTextarea>
{:else}
<InputText
{id}
{label}
bind:value
{nullable}
{autofocus}
{maxlength}
bind:value={stringValue}
placeholder="Enter string"
required={column.required} />
{/if}
@@ -3,7 +3,7 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Alert, Confirm, Id, SortButton } from '$lib/components';
import { Confirm, Id, SortButton } from '$lib/components';
import { Dependencies, SPREADSHEET_PAGE_LIMIT } from '$lib/constants';
import { Button as ConsoleButton, InputChoice, InputSelect } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
@@ -32,12 +32,12 @@
rowActivitySheet,
paginatedRows,
paginatedRowsLoading,
spreadsheetRenderKey,
expandTabs
spreadsheetRenderKey
} from './store';
import RelationshipsModal from './relationshipsModal.svelte';
import type { Column, ColumnType } from '$lib/helpers/types';
import {
Alert,
Tooltip,
Spreadsheet,
Table,
@@ -53,6 +53,7 @@
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import {
IconArrowSmRight,
IconCalendar,
IconDotsHorizontal,
IconFingerPrint,
@@ -62,6 +63,7 @@
IconMail,
IconPlus,
IconRelationship,
IconSwitchHorizontal,
IconText,
IconToggle,
IconViewList
@@ -98,7 +100,12 @@
const emptyCellsLimit = $isSmallViewport ? 12 : 18;
const SYSTEM_KEYS = new Set([
'$tableId',
'$databaseId'
'$databaseId',
'$permissions',
'$createdAt',
'$updatedAt',
'$id',
'$sequence'
]); /* TODO: should be fixed at the sdk level! */
let selectedRows = [];
@@ -138,7 +145,7 @@
const baseColumns = $table.columns.map((col) => ({
id: col.key,
title: col.key,
title: col.array ? `${col.key} []` : col.key,
type: col.type as ColumnType,
hide: !!selectedColumnsToHide?.includes(col.key),
array: col?.array,
@@ -514,9 +521,10 @@
Object.entries(row).filter(([key]) => !SYSTEM_KEYS.has(key))
);
// TODO | BUG: related rows still have `system` columns atm!
await sdk.forProject(page.params.region, page.params.project).grids.updateRow({
databaseId,
tableId,
tableId: $table.$id,
rowId: row.$id,
data: onlyData,
permissions: row.$permissions
@@ -644,10 +652,6 @@
$: totalPages = Math.ceil($rows.total / SPREADSHEET_PAGE_LIMIT) || 1;
$: rowSelection = !$spreadsheetLoading && !$paginatedRowsLoading ? true : ('disabled' as const);
expandTabs.subscribe((expanded) => {
preferences.setTableHeaderExpanded(tableId, expanded);
});
</script>
<SpreadsheetContainer observeExpand bind:this={spreadsheetContainer}>
@@ -867,14 +871,17 @@
{/if}
{/if}
<svelte:fragment slot="cell-editor">
<svelte:fragment slot="cell-editor" let:close>
<EditRowCell
{row}
column={rowColumn}
row={paginatedRows.getItemAtVirtualIndex(index)}
onRowStructureUpdate={updateRowContents}
on:change={(row) => paginatedRows.update(index, row.detail)}
on:revert={(row) =>
paginatedRows.update(index, row.detail)} />
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
openSideSheet={() => {
close(); /* closes the editor */
onSelectSheetOption('update', null, 'row', row);
}} />
</svelte:fragment>
</Spreadsheet.Cell>
{/each}
@@ -990,23 +997,24 @@
<Table.Root
let:root
columns={[
{ id: 'relation', width: 150 },
{ id: 'setting', width: 150 },
{ id: 'desc' }
{ id: 'relation', width: 100 },
{ id: 'setting', width: 100 },
{ id: 'description', width: { min: $isSmallViewport ? 300 : 275 } }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="relation" {root}>Relation</Table.Header.Cell>
<Table.Header.Cell column="setting" {root}>Setting</Table.Header.Cell>
<Table.Header.Cell column="desc" {root} />
<Table.Header.Cell column="description" {root}>Description</Table.Header.Cell>
</svelte:fragment>
{#each relatedColumns as attr}
<Table.Row.Base {root}>
<Table.Cell column="relation" {root}>
<span class="u-flex u-cross-center u-gap-8">
{#if attr.twoWay}
<span class="icon-switch-horizontal"></span>
<Icon icon={IconSwitchHorizontal} size="s" />
{:else}
<span class="icon-arrow-sm-right"></span>
<Icon icon={IconArrowSmRight} size="s" />
{/if}
<span data-private>{attr.key}</span>
</span>
@@ -1014,25 +1022,27 @@
<Table.Cell column="setting" {root}>
{attr.onDelete}
</Table.Cell>
<Table.Cell column="desc" {root}>
<Table.Cell column="description" {root}>
{Deletion[attr.onDelete]}
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
<div class="u-flex u-flex-vertical u-gap-16">
<Alert>To change the selection edit the relationship settings.</Alert>
<Layout.Stack direction="column" gap="m">
<Alert.Inline>To change the selection edit the relationship settings.</Alert.Inline>
<ul>
<InputChoice
id="delete"
label="Delete"
showLabel={false}
bind:value={deleteConfirmationChecked}>
Delete {isSingle ? 'document' : 'documents'} from
Delete {isSingle ? 'row' : 'rows'} from
<span data-private>{$table.name}</span>
</InputChoice>
</ul>
</div>
</Layout.Stack>
{:else}
<p class="u-bold">This action is irreversible.</p>
{/if}
@@ -1066,19 +1076,31 @@
}
:global(.floating-editor) {
z-index: 3 !important;
& :global(:has(textarea)) {
left: 0 !important;
margin-inline-end: 1px;
left: 2px !important;
//margin-inline-end: 1px;
}
/* TODO: not good! */
& :global(textarea) {
padding-inline: 9px;
margin-block: -2.75px;
min-height: 85px !important;
}
/* TODO: not good! */
& :global(:has(.link-wrapper) .input) {
padding-bottom: 6px !important;
}
& :global(:has(.link-wrapper) textarea) {
min-height: 65px !important;
}
& :global(.link-wrapper) {
padding-inline: 9px;
}
& :global(input[type='text']) {
padding-inline: 8px !important;
}
@@ -139,7 +139,7 @@ export const randomDataModalState = writable({
export const spreadsheetLoading = writable(false);
export enum Deletion {
'setNull' = 'Set rpw ID as NULL in all related rows',
'setNull' = 'Set row ID as NULL in all related rows',
'cascade' = 'All related rows will be deleted',
'restrict' = 'Row can not be deleted'
}
@@ -151,7 +151,7 @@ export const rowActivitySheet = writable({
row: null as Models.Row
});
export const expandTabs = writable(true);
export const expandTabs = writable(null);
export const spreadsheetRenderKey = writable('initial');
export const paginatedRowsLoading = writable(false);