Merge pull request #216 from appwrite/feat-empty-arrays-ui

Feat: empty arrays UI
This commit is contained in:
Torsten Dittmann
2023-02-09 16:21:51 +01:00
committed by GitHub
24 changed files with 307 additions and 189 deletions
+9 -2
View File
@@ -1,17 +1,24 @@
import type { Action } from 'svelte/action';
import type { Props } from 'tippy.js';
import type { Props as TippyProps } from 'tippy.js';
import tippy from 'tippy.js';
type Props = TippyProps & {
disabled?: boolean;
};
export const tooltip: Action<HTMLElement, Partial<Props>> = (node, config) => {
const instance = tippy(node, config);
if (config.disabled) instance.disable();
return {
update({ content }) {
update({ content, disabled }) {
if (content !== instance.props.content) {
instance.setProps({
content
});
}
disabled ? instance.disable() : instance.enable();
},
destroy() {
instance.destroy();
+8 -1
View File
@@ -2,6 +2,7 @@
import { FormItem, Helper } from '.';
export let label: string;
export let optionalText: string | undefined = undefined;
export let showLabel = true;
export let id: string;
export let value = false;
@@ -26,7 +27,13 @@
</script>
<FormItem>
<label class:u-hide={!showLabel} class="label" for={id}>{label}</label>
<label class:u-hide={!showLabel} class="label" for={id}>
{label}
{#if optionalText}
<span class="optional">{optionalText}</span>
{/if}
</label>
<div class="input-text-wrapper">
<input
{id}
+8 -1
View File
@@ -4,6 +4,7 @@
export let label: string;
export let showLabel = true;
export let optionalText: string | undefined = undefined;
export let id: string;
export let value = '';
export let required = false;
@@ -38,7 +39,13 @@
</script>
<FormItem>
<label class:u-hide={!showLabel} class="label" for={id}>{label}</label>
<label class:u-hide={!showLabel} class="label" for={id}>
{label}
<span class:u-hide={!showLabel || !optionalText} class="optional">
{optionalText}
</span>
</label>
<div class="input-text-wrapper">
<input
{id}
+8 -1
View File
@@ -3,6 +3,7 @@
import { FormItem, Helper } from '.';
export let label: string;
export let optionalText: string | undefined = undefined;
export let showLabel = true;
export let id: string;
export let value: number = null;
@@ -51,7 +52,13 @@
</script>
<FormItem>
<label class:u-hide={!showLabel} class="label" for={id}>{label}</label>
<label class:u-hide={!showLabel} class="label" for={id}>
{label}
{#if optionalText}
<span class="optional">{optionalText}</span>
{/if}
</label>
<div class="input-text-wrapper">
<input
{id}
+8 -1
View File
@@ -3,6 +3,7 @@
export let id: string;
export let label: string;
export let optionalText: string | undefined = undefined;
export let showLabel = true;
export let value: string | number | boolean;
export let placeholder = '';
@@ -32,7 +33,13 @@
</script>
<FormItem>
<label class:u-hide={!showLabel} class="label" for={id}>{label}</label>
<label class:u-hide={!showLabel} class="label" for={id}>
{label}
{#if optionalText}
<span class="optional">{optionalText}</span>
{/if}
</label>
<div class="select">
<select
{id}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { last } from '$lib/helpers/array';
import { onMount } from 'svelte';
import { FormItem, Helper } from '.';
@@ -36,7 +37,7 @@
}
if (['Backspace', 'Delete'].includes(e.key)) {
if (value.length === 0) {
removeValue(tags[tags.length - 1]);
removeValue(last(tags));
}
}
};
+7 -2
View File
@@ -4,6 +4,7 @@
import { FormItem, Helper } from '.';
export let label: string;
export let optionalText: string | undefined = undefined;
export let showLabel = true;
export let id: string;
export let value = '';
@@ -42,8 +43,8 @@
</script>
<FormItem>
<label class:u-hide={!showLabel} class="label" for={id}
>{label}
<label class:u-hide={!showLabel} class="label" for={id}>
{label}
{#if tooltip}
<span
class="icon-info"
@@ -52,7 +53,11 @@
content: tooltip
}} />
{/if}
{#if optionalText}
<span class="optional">{optionalText}</span>
{/if}
</label>
<div class="input-text-wrapper">
<input
{id}
+9
View File
@@ -0,0 +1,9 @@
/**
* Strict typed `Object.entries`
* Extracted from https://github.com/antfu/utils
*
* @category Object
*/
export function objectEntries<T extends object>(obj: T) {
return Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
}
+11
View File
@@ -1,3 +1,14 @@
/**
* Capitalizes the first letter of a string
*
* @export
* @param {string} str
* @returns {string}
*/
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Given a string, returns the singular version of it,
* by removing the trailing 's'.
@@ -19,6 +19,7 @@
import { page } from '$app/stores';
import { PAGE_LIMIT } from '$lib/constants';
import CreateAttribute from '../createAttribute.svelte';
import { tooltip } from '$lib/actions/tooltip';
export let data: PageData;
let showCreateAttribute = false;
@@ -33,6 +34,44 @@
title: attribute.key
}))
];
function formatArray(array: unknown[]) {
if (array.length === 0) return '[ ]';
let formattedFields: string[] = [];
for (const item of array) {
if (typeof item === 'string') {
formattedFields.push(`"${item}"`);
} else {
formattedFields.push(`${item}`);
}
}
return `[${formattedFields.join(', ')}]`;
}
function formatColumn(column: unknown) {
let formattedColumn: string;
if (typeof column === 'string') {
formattedColumn = column;
} else if (Array.isArray(column)) {
formattedColumn = formatArray(column);
} else if (!column) {
formattedColumn = 'n/a';
} else {
formattedColumn = `${column}`;
}
return {
value:
formattedColumn.length > 20
? `${formattedColumn.slice(0, 20)}...`
: formattedColumn,
truncated: formattedColumn.length > 20,
whole: formattedColumn
};
}
</script>
<Container>
@@ -70,8 +109,15 @@
</Copy>
</TableCell>
{#each columns as column}
{@const formatted = formatColumn(document[column.key])}
<TableCell>
{document[column.key] ?? 'n/a'}
<div
use:tooltip={{
content: formatted.whole,
disabled: !formatted.truncated
}}>
{formatted.value}
</div>
</TableCell>
{/each}
</TableRowLink>
@@ -23,7 +23,7 @@
$createDocument.attributes = $attributes.filter((a) => a.status === 'available');
$attributes.forEach((attr) => {
if (attr.array) {
$createDocument.document[attr.key] = [null];
$createDocument.document[attr.key] = [];
} else {
$createDocument.document[attr.key] = null;
}
@@ -39,12 +39,15 @@
$createDocument.document,
$createDocument.permissions
);
addNotification({
message: 'Document has been created',
type: 'success'
});
trackEvent('submit_document_create');
invalidate(Dependencies.DOCUMENTS);
createDocument.reset();
wizard.hide();
} catch (error) {
addNotification({
@@ -9,6 +9,7 @@
export let id: string;
export let label: string;
export let optionalText: string | undefined = undefined;
export let value: string | number | boolean | null;
export let attribute:
| Models.AttributeBoolean
@@ -44,6 +45,7 @@
{id}
{label}
{attribute}
{optionalText}
bind:value />
{:else}
<svelte:component
@@ -51,6 +53,7 @@
{id}
{label}
{attribute}
{optionalText}
bind:value />
{/if}
{/if}
@@ -0,0 +1,113 @@
<script lang="ts">
import CustomId from '$lib/components/customId.svelte';
import { FormList } from '$lib/elements/forms';
import Button from '$lib/elements/forms/button.svelte';
import Pill from '$lib/elements/pill.svelte';
import { capitalize } from '$lib/helpers/string';
import type { Attributes } from '../store';
import Attribute from './attribute.svelte';
export let attributes: Attributes[] = [];
export let formValues: object = {};
export let customId: string | null | undefined = undefined;
export let gap: '16' | '32' = '32';
let showCustomId = false;
function removeArrayItem(key: string, index: number) {
formValues = {
...formValues,
[key]: formValues[key].filter((_, i) => i !== index)
};
}
function addArrayItem(key: string) {
formValues = {
...formValues,
[key]: [...formValues[key], null]
};
}
function getAttributeType(attribute: Attributes) {
if ('format' in attribute && attribute.format === 'enum') return 'Enum';
return `${capitalize(attribute.type)}${attribute.array ? '[]' : ''}`;
}
</script>
{#if attributes.length}
<ul class={`form-list u-gap-${gap}`}>
{#each attributes as attribute}
{@const label = attribute.required ? `${attribute.key}*` : attribute.key}
{#if attribute.array}
{#if formValues[attribute.key].length === 0}
<div class="u-flex u-cross-center u-main-space-between">
<span class="label u-margin-0">
{label}
<span class="optional">
{getAttributeType(attribute)}
</span>
</span>
<Button text noMargin on:click={() => addArrayItem(attribute.key)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text"> Add item</span>
</Button>
</div>
{/if}
{#if formValues[attribute.key].length !== 0}
<ul class="u-grid u-gap-8">
{#each [...formValues[attribute.key].keys()] as index}
<li class="form-item is-multiple">
<div class="form-item-part u-stretch">
<Attribute
{attribute}
id={`${attribute.key}-${index}`}
optionalText={index === 0
? getAttributeType(attribute)
: undefined}
label={index === 0 ? label : ''}
bind:value={formValues[attribute.key][index]} />
</div>
<div class="form-item-part u-cross-child-end">
<Button
text
on:click={() => removeArrayItem(attribute.key, index)}>
<span class="icon-x" aria-hidden="true" />
</Button>
</div>
</li>
{/each}
</ul>
<Button text noMargin on:click={() => addArrayItem(attribute.key)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text"> Add item</span>
</Button>
{/if}
{:else}
<FormList>
<Attribute
{attribute}
{label}
optionalText={getAttributeType(attribute)}
id={attribute.key}
bind:value={formValues[attribute.key]} />
</FormList>
{/if}
{/each}
{#if customId !== undefined}
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}>
<span class="icon-pencil" aria-hidden="true" /><span class="text">
Document ID
</span>
</Pill>
</div>
{:else}
<CustomId bind:show={showCustomId} name="Document" bind:id={customId} />
{/if}
{/if}
</ul>
{/if}
@@ -6,12 +6,14 @@
export let label: string;
export let value: boolean;
export let attribute: Models.AttributeBoolean;
export let optionalText: string | undefined = undefined;
</script>
{#if attribute?.array}
<InputSelect
{id}
{label}
{optionalText}
showLabel={!!label?.length}
placeholder="Select a value"
required={attribute.required}
@@ -21,5 +23,5 @@
]}
bind:value />
{:else}
<InputCheckbox {id} {label} required={attribute.required} bind:value />
<InputCheckbox {id} {label} {optionalText} required={attribute.required} bind:value />
{/if}
@@ -6,6 +6,13 @@
export let label: string;
export let value: string;
export let attribute: Models.AttributeDatetime;
export let optionalText: string | undefined = undefined;
</script>
<InputDateTime {id} {label} showLabel={!!label?.length} required={attribute.required} bind:value />
<InputDateTime
{id}
{label}
{optionalText}
showLabel={!!label?.length}
required={attribute.required}
bind:value />
@@ -6,6 +6,7 @@
export let label: string;
export let value: string;
export let attribute: Models.AttributeEnum;
export let optionalText: string | undefined = undefined;
$: options = attribute.elements.map((element) => {
return {
@@ -20,5 +21,6 @@
{options}
{id}
{label}
{optionalText}
placeholder="Select a value"
showLabel={!!label?.length} />
@@ -6,11 +6,13 @@
export let label: string;
export let value: number;
export let attribute: Models.AttributeInteger;
export let optionalText: string | undefined = undefined;
</script>
<InputNumber
{id}
{label}
{optionalText}
required={attribute.required}
min={attribute.min}
max={attribute.max}
@@ -6,11 +6,13 @@
export let label: string;
export let value: string;
export let attribute: Models.AttributeString;
export let optionalText: string | undefined = undefined;
</script>
<InputText
{id}
{label}
{optionalText}
showLabel={!!label?.length}
required={attribute.required}
maxlength={attribute.size}
@@ -11,27 +11,27 @@
import type { Models } from '@aw-labs/appwrite-console';
import { Dependencies } from '$lib/constants';
import { invalidate } from '$app/navigation';
import Attribute from './attribute.svelte';
import { trackEvent } from '$lib/actions/analytics';
import AttributeForm from './attributeForm.svelte';
let disableUpdate = true;
let currentDoc: string;
const databaseId = $page.params.database;
const collectionId = $page.params.collection;
const documentId = $page.params.document;
const work = writable(
Object.keys($doc)
.filter(
(key) =>
![
'$id',
'$collection',
'$collectionId',
'$databaseId',
'$createdAt',
'$updatedAt'
].includes(key)
)
.filter((key) => {
return ![
'$id',
'$collection',
'$collectionId',
'$databaseId',
'$createdAt',
'$updatedAt'
].includes(key);
})
.reduce((obj, key) => {
obj[key] = $doc[key];
return obj;
@@ -42,7 +42,7 @@
currentDoc = JSON.stringify($work);
});
$: if (currentDoc && $work) {
$: {
if (currentDoc !== JSON.stringify($work)) {
disableUpdate = false;
} else {
@@ -77,90 +77,13 @@
});
}
}
function addArrayItem(key: string) {
work.update((n) => {
if (!Array.isArray(n[key])) {
n[key] = [];
}
n[key].push(null);
return n;
});
}
function removeArrayItem(key: string, index: number) {
work.update((n) => {
n[key].splice(index, 1);
return n;
});
}
</script>
<CardGrid>
<Heading tag="h6" size="7">Update Data</Heading>
<p>Update document data based on the attributes created earlier.</p>
<svelte:fragment slot="aside">
<form class="form u-grid u-gap-16">
{#each $collection.attributes.filter((a) => a.status === 'available') as attribute}
{#if attribute.array}
<ul class="form-list">
{#each [...$work[attribute.key].keys()] as index}
<li class="form-item is-multiple">
<div class="form-item-part u-stretch">
<Attribute
{attribute}
id={`${attribute.key}-${index}`}
label={index === 0 ? attribute.key : ''}
bind:value={$work[attribute.key][index]} />
</div>
<div class="form-item-part u-cross-child-end">
<Button
text
disabled={$work[attribute.key].length === 1}
on:click={() => removeArrayItem(attribute.key, index)}>
<span class="icon-x" aria-hidden="true" />
</Button>
</div>
</li>
{:else}
<li class="form-item is-multiple">
<div class="form-item-part u-stretch">
<Attribute
{attribute}
id={`${attribute.key}-0`}
label={attribute.key}
bind:value={$work[attribute.key][0]} />
</div>
<div class="form-item-part u-cross-child-end">
<Button text disabled>
<span class="icon-x" aria-hidden="true" />
</Button>
</div>
</li>
{/each}
</ul>
<Button
text
noMargin
disabled={$work[attribute.key][$work[attribute.key].length - 1] === null}
on:click={() => addArrayItem(attribute.key)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add item</span>
</Button>
{:else}
<ul class="form-list">
<Attribute
{attribute}
id={attribute.key}
label={attribute.key}
bind:value={$work[attribute.key]} />
</ul>
{/if}
{/each}
</form>
<AttributeForm attributes={$collection.attributes} bind:formValues={$work} gap="16" />
</svelte:fragment>
<svelte:fragment slot="actions">
@@ -1,6 +1,6 @@
import { derived } from 'svelte/store';
import { page } from '$app/stores';
import type { Models } from '@aw-labs/appwrite-console';
import { derived } from 'svelte/store';
export type Attributes =
| Models.AttributeBoolean
@@ -1,28 +1,7 @@
<script lang="ts">
import { CustomId } from '$lib/components';
import { Pill } from '$lib/elements';
import { Button, FormList } from '$lib/elements/forms';
import { WizardStep } from '$lib/layout';
import AttributeForm from '../document-[document]/attributeForm.svelte';
import { createDocument } from './store';
import Attribute from '../document-[document]/attribute.svelte';
let showCustomId = false;
function addArrayItem(key: string) {
createDocument.update((n) => {
n.document[key].push(null);
return n;
});
}
function removeArrayItem(key: string, index: number) {
createDocument.update((n) => {
n.document[key].splice(index, 1);
return n;
});
}
</script>
<WizardStep>
@@ -31,61 +10,8 @@
Provide document data based on attributes you created earlier.
</svelte:fragment>
{#if $createDocument.attributes.length}
<FormList>
{#each $createDocument.attributes as attribute}
{@const label = attribute.required ? `${attribute.key}*` : attribute.key}
{#if attribute.array}
{#each [...$createDocument.document[attribute.key].keys()] as index}
<li class="form-item is-multiple">
<div class="form-item-part u-stretch">
<Attribute
{attribute}
id={`${attribute.key}-${index}`}
label={index === 0 ? label : ''}
bind:value={$createDocument.document[attribute.key][index]} />
</div>
<div class="form-item-part u-cross-child-end">
<Button
text
disabled={index === 0}
on:click={() => removeArrayItem(attribute.key, index)}>
<span class="icon-x" aria-hidden="true" />
</Button>
</div>
</li>
{/each}
<Button
text
noMargin
disabled={$createDocument.document[attribute.key][
$createDocument.document[attribute.key].length - 1
] === null}
on:click={() => addArrayItem(attribute.key)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text"> Add item</span>
</Button>
{:else}
<FormList>
<Attribute
{attribute}
{label}
id={attribute.key}
bind:value={$createDocument.document[attribute.key]} />
</FormList>
{/if}
{/each}
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}>
<span class="icon-pencil" aria-hidden="true" /><span class="text">
Document ID
</span>
</Pill>
</div>
{:else}
<CustomId bind:show={showCustomId} name="Document" bind:id={$createDocument.id} />
{/if}
</FormList>
{/if}
<AttributeForm
bind:formValues={$createDocument.document}
attributes={$createDocument.attributes}
bind:customId={$createDocument.id} />
</WizardStep>
@@ -1,14 +1,31 @@
import { writable } from 'svelte/store';
import type { Attributes } from '../store';
export const createDocument = writable<{
type CreateDocument = {
id?: string;
document: object;
permissions: string[];
attributes: Attributes[];
}>({
};
const initialCreateDocument: CreateDocument = {
id: null,
document: {},
permissions: [],
attributes: []
});
};
function createDocumentWritable() {
const store = writable<CreateDocument>({ ...initialCreateDocument });
const reset = () => {
store.set({ ...initialCreateDocument });
};
return {
...store,
reset
};
}
export const createDocument = createDocumentWritable();
+2 -2
View File
@@ -72,13 +72,13 @@ test('shows text input - maxlength', () => {
});
test('state', async () => {
const { component, getByLabelText } = render(InputText, {
const { component, container } = render(InputText, {
id: 'input',
label: 'input',
value: ''
});
const input = getByLabelText('input');
const input = container.querySelector('input');
expect(component.value).toEqual('');
await userEvent.type(input, 'lorem');
expect(component.value).toEqual('lorem');
+11
View File
@@ -0,0 +1,11 @@
import { objectEntries } from '$lib/helpers/object';
test('objectEntries', () => {
const object = {
a: 1,
b: 2,
c: 3
};
expect(objectEntries(object)).toEqual(Object.entries(object));
});