mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
feat:env var pagination
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { BackupRestoreBox, MigrationBox, UploadBox, CsvExportBox } from '$lib/components';
|
||||
import VariablesImportBox from './variablesImportBox.svelte';
|
||||
import { realtime } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { project, stats } from './store';
|
||||
@@ -187,6 +188,7 @@
|
||||
<BackupRestoreBox />
|
||||
<CsvImportBox />
|
||||
<CsvExportBox />
|
||||
<VariablesImportBox />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -8,14 +8,25 @@
|
||||
import { parse } from '$lib/helpers/envfile';
|
||||
import { Icon, InlineCode, Layout, Tabs } from '@appwrite.io/pink-svelte';
|
||||
import { InputTextarea } from '$lib/elements/forms';
|
||||
import { IconDownload, IconDuplicate } from '@appwrite.io/pink-icons-svelte';
|
||||
import {
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconDownload,
|
||||
IconDuplicate
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
export let isGlobal: boolean;
|
||||
export let showEditor = false;
|
||||
export let variableList: Models.VariableList;
|
||||
|
||||
type DraftVariable = { key: string; value: string };
|
||||
|
||||
const EDITOR_PAGE_LIMIT = 10;
|
||||
const editableVariables = variableList.variables.filter((variable) => !variable.secret);
|
||||
const secretVariables = variableList.variables.filter((variable) => variable.secret);
|
||||
const initialDraftEntries = editableVariables.map((variable) => ({
|
||||
key: variable.key,
|
||||
value: variable.value
|
||||
}));
|
||||
|
||||
export let sdkCreateVariable: (
|
||||
key: string,
|
||||
@@ -31,39 +42,109 @@
|
||||
export let sdkDeleteVariable: (variableId: string) => Promise<unknown>;
|
||||
|
||||
let error = '';
|
||||
let envCode = editableVariables
|
||||
.map((variable) => `${variable.key}=${variable.value}`)
|
||||
.join('\n');
|
||||
let jsonCode = JSON.stringify(
|
||||
JSON.parse(
|
||||
`{${editableVariables
|
||||
.map((variable) => `"${variable.key}":"${variable.value.split('"').join('\\"')}"`)
|
||||
.join(',')}}`
|
||||
),
|
||||
null,
|
||||
2
|
||||
);
|
||||
let baseEnvCode = envCode;
|
||||
let baseJsonCode = jsonCode;
|
||||
let tab: 'env' | 'json' = 'env';
|
||||
let isSubmitting = false;
|
||||
let pageOffset = 0;
|
||||
let draftEntries = initialDraftEntries.map((variable) => ({ ...variable }));
|
||||
let editorCode = serializeEntries(tab, getPageEntries(draftEntries, pageOffset));
|
||||
|
||||
if (jsonCode === '{}') {
|
||||
jsonCode = '';
|
||||
baseJsonCode = '';
|
||||
function getPageEntries(entries: DraftVariable[], offset: number) {
|
||||
return entries.slice(offset, offset + EDITOR_PAGE_LIMIT);
|
||||
}
|
||||
|
||||
let tab: 'env' | 'json' = 'env';
|
||||
function serializeEntries(view: 'env' | 'json', entries: DraftVariable[]) {
|
||||
if (view === 'env') {
|
||||
return entries.map((variable) => `${variable.key}=${variable.value}`).join('\n');
|
||||
}
|
||||
|
||||
const jsonObject = Object.fromEntries(
|
||||
entries.map((variable) => [variable.key, variable.value])
|
||||
);
|
||||
|
||||
return Object.keys(jsonObject).length ? JSON.stringify(jsonObject, null, 2) : '';
|
||||
}
|
||||
|
||||
function parseEntries(code: string, view: 'env' | 'json') {
|
||||
const variables = view === 'env' ? parse(code) : JSON.parse(code || '{}');
|
||||
|
||||
return Object.entries(variables).map(([key, value]) => ({
|
||||
key,
|
||||
value: `${value}`
|
||||
}));
|
||||
}
|
||||
|
||||
function validateEntries(entries: DraftVariable[]) {
|
||||
for (const { key, value } of entries) {
|
||||
if (value.length > 8192) {
|
||||
throw new Error(`Variable ${key} is longer than 8192 allowed characters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replacePageEntries(
|
||||
entries: DraftVariable[],
|
||||
offset: number,
|
||||
pageEntries: DraftVariable[]
|
||||
) {
|
||||
return [
|
||||
...entries.slice(0, offset),
|
||||
...pageEntries,
|
||||
...entries.slice(offset + EDITOR_PAGE_LIMIT)
|
||||
];
|
||||
}
|
||||
|
||||
function buildDraftEntries() {
|
||||
const pageEntries = parseEntries(editorCode, tab);
|
||||
|
||||
validateEntries(pageEntries);
|
||||
|
||||
return replacePageEntries(draftEntries, pageOffset, pageEntries);
|
||||
}
|
||||
|
||||
function syncEditorCode() {
|
||||
editorCode = serializeEntries(tab, getPageEntries(draftEntries, pageOffset));
|
||||
}
|
||||
|
||||
function trySyncDraft() {
|
||||
try {
|
||||
draftEntries = buildDraftEntries();
|
||||
error = '';
|
||||
} catch {
|
||||
// Keep draft state intact while the user is mid-edit.
|
||||
}
|
||||
}
|
||||
|
||||
function changeTab(nextTab: 'env' | 'json') {
|
||||
try {
|
||||
draftEntries = buildDraftEntries();
|
||||
tab = nextTab;
|
||||
error = '';
|
||||
syncEditorCode();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(nextOffset: number) {
|
||||
try {
|
||||
draftEntries = buildDraftEntries();
|
||||
pageOffset = nextOffset;
|
||||
error = '';
|
||||
syncEditorCode();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const vars = tab === 'env' ? parse(envCode) : JSON.parse(jsonCode ? jsonCode : '{}');
|
||||
isSubmitting = true;
|
||||
|
||||
const entries = Object.entries(vars);
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
if (('' + value).length > 8192) {
|
||||
throw new Error(`Variable ${key} is longer than 8192 allowed characters`);
|
||||
}
|
||||
}
|
||||
const vars = Object.fromEntries(
|
||||
buildDraftEntries().map((variable) => [variable.key, variable.value])
|
||||
);
|
||||
const editableVariables = variableList.variables.filter((variable) => !variable.secret);
|
||||
const secretVariables = variableList.variables.filter((variable) => variable.secret);
|
||||
|
||||
await Promise.all(
|
||||
editableVariables.map(async (variable) => {
|
||||
@@ -81,9 +162,20 @@
|
||||
// Add new variables, skipping keys that exist in secret variables
|
||||
await Promise.all(
|
||||
Object.keys(vars).map(async (key) => {
|
||||
if (!secretVariables.some((v) => v.key === key)) {
|
||||
await sdkCreateVariable(key, vars[key], false);
|
||||
const existingVariable = variableList.variables.find(
|
||||
(variable) => variable.key === key
|
||||
);
|
||||
|
||||
if (existingVariable?.secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingVariable) {
|
||||
await sdkUpdateVariable(existingVariable.$id, key, vars[key], false);
|
||||
return;
|
||||
}
|
||||
|
||||
await sdkCreateVariable(key, vars[key], false);
|
||||
})
|
||||
);
|
||||
// Ensure secret variables are preserved
|
||||
@@ -103,11 +195,25 @@
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
trackError(e, Submit.VariableEditor);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadVariables() {
|
||||
const content = tab === 'json' ? jsonCode : envCode;
|
||||
let content = '';
|
||||
|
||||
try {
|
||||
const nextDraftEntries = buildDraftEntries();
|
||||
|
||||
content = serializeEntries(tab, nextDraftEntries);
|
||||
draftEntries = nextDraftEntries;
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = tab === 'json' ? 'vars.json' : '.env';
|
||||
const type = tab === 'json' ? 'application/json' : 'application/x-envoy';
|
||||
|
||||
@@ -127,8 +233,19 @@
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
$: totalEntries = draftEntries.length;
|
||||
$: currentPageEntries = getPageEntries(draftEntries, pageOffset);
|
||||
$: renderedPageCode = serializeEntries(tab, currentPageEntries);
|
||||
$: hasUnsyncedChanges = editorCode !== renderedPageCode;
|
||||
$: copyValue = (() => {
|
||||
try {
|
||||
return serializeEntries(tab, buildDraftEntries());
|
||||
} catch {
|
||||
return editorCode;
|
||||
}
|
||||
})();
|
||||
$: isButtonDisabled =
|
||||
(tab === 'env' && baseEnvCode === envCode) || (tab === 'json' && baseJsonCode === jsonCode);
|
||||
!hasUnsyncedChanges && JSON.stringify(draftEntries) === JSON.stringify(initialDraftEntries);
|
||||
</script>
|
||||
|
||||
<Modal title="Editor" bind:show={showEditor} onSubmit={handleSubmit} bind:error>
|
||||
@@ -138,10 +255,10 @@
|
||||
</p>
|
||||
<Layout.Stack gap="s">
|
||||
<Tabs.Root stretch let:root>
|
||||
<Tabs.Item.Button {root} on:click={() => (tab = 'env')} active={tab === 'env'}>
|
||||
<Tabs.Item.Button {root} on:click={() => changeTab('env')} active={tab === 'env'}>
|
||||
ENV
|
||||
</Tabs.Item.Button>
|
||||
<Tabs.Item.Button {root} on:click={() => (tab = 'json')} active={tab === 'json'}>
|
||||
<Tabs.Item.Button {root} on:click={() => changeTab('json')} active={tab === 'json'}>
|
||||
JSON
|
||||
</Tabs.Item.Button>
|
||||
</Tabs.Root>
|
||||
@@ -151,23 +268,53 @@
|
||||
<InputTextarea
|
||||
spellcheck={false}
|
||||
id="variables"
|
||||
bind:value={envCode}
|
||||
bind:value={editorCode}
|
||||
on:input={trySyncDraft}
|
||||
rows={10}
|
||||
placeholder={`SECRET_KEY=dQw4w9WgXcQ...`} />
|
||||
{:else if tab === 'json'}
|
||||
<InputTextarea
|
||||
spellcheck={false}
|
||||
id="variables"
|
||||
bind:value={jsonCode}
|
||||
bind:value={editorCode}
|
||||
on:input={trySyncDraft}
|
||||
rows={10}
|
||||
placeholder={`{\n "SECRET_KEY": "dQw4w9WgXcQ..."\n}`} />
|
||||
{/if}
|
||||
{#if totalEntries > EDITOR_PAGE_LIMIT}
|
||||
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<p class="text">
|
||||
Variables {pageOffset + 1}-{Math.min(
|
||||
pageOffset + EDITOR_PAGE_LIMIT,
|
||||
totalEntries
|
||||
)} of {totalEntries}
|
||||
</p>
|
||||
<Layout.Stack direction="row" gap="xs" inline>
|
||||
<Button
|
||||
size="xs"
|
||||
secondary
|
||||
disabled={pageOffset === 0}
|
||||
on:click={() => changePage(pageOffset - EDITOR_PAGE_LIMIT)}>
|
||||
<Icon size="s" slot="start" icon={IconChevronLeft} />
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
secondary
|
||||
disabled={pageOffset + EDITOR_PAGE_LIMIT >= totalEntries}
|
||||
on:click={() => changePage(pageOffset + EDITOR_PAGE_LIMIT)}>
|
||||
Next
|
||||
<Icon size="s" slot="end" icon={IconChevronRight} />
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
<Layout.Stack direction="row" gap="xs">
|
||||
<Button size="xs" on:click={() => downloadVariables()} secondary>
|
||||
<Icon size="s" slot="start" icon={IconDownload} />
|
||||
Download
|
||||
</Button>
|
||||
<Copy value={tab == 'json' ? jsonCode : envCode}>
|
||||
<Copy value={copyValue}>
|
||||
<Button size="xs" secondary>
|
||||
<Icon size="s" slot="start" icon={IconDuplicate} />
|
||||
Copy
|
||||
@@ -178,7 +325,11 @@
|
||||
</Layout.Stack>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (showEditor = false)}>Cancel</Button>
|
||||
<Button submit disabled={isButtonDisabled}>Save</Button>
|
||||
<Button secondary on:click={() => (showEditor = false)} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button submit disabled={isButtonDisabled || isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
{sdkDeleteVariable}
|
||||
isGlobal
|
||||
variableList={data.variables}
|
||||
allVariableList={data.allVariables}
|
||||
backendPagination
|
||||
variablesOffset={data.variablesOffset}
|
||||
variablesLimit={data.limit}
|
||||
project={data.project}
|
||||
analyticsSource="project_settings" />
|
||||
<ChangeOrganization />
|
||||
|
||||
@@ -8,10 +8,36 @@ export const load: PageLoad = async ({ depends, url, params }) => {
|
||||
depends(Dependencies.PROJECT_INSTALLATIONS);
|
||||
const limit = PAGE_LIMIT;
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0);
|
||||
const variablesOffset = Number(url.searchParams.get('variablesOffset') ?? 0);
|
||||
const projectSdk = sdk.forProject(params.region, params.project);
|
||||
|
||||
const [variables, installations] = await Promise.all([
|
||||
sdk.forProject(params.region, params.project).projectApi.listVariables(),
|
||||
sdk.forProject(params.region, params.project).vcs.listInstallations({
|
||||
async function listAllVariables() {
|
||||
const allVariables = [];
|
||||
let nextOffset = 0;
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
const response = await projectSdk.projectApi.listVariables({
|
||||
queries: [Query.limit(limit), Query.offset(nextOffset)]
|
||||
});
|
||||
|
||||
allVariables.push(...response.variables);
|
||||
total = response.total;
|
||||
nextOffset += response.variables.length;
|
||||
} while (nextOffset < total);
|
||||
|
||||
return {
|
||||
total,
|
||||
variables: allVariables
|
||||
};
|
||||
}
|
||||
|
||||
const [variables, allVariables, installations] = await Promise.all([
|
||||
projectSdk.projectApi.listVariables({
|
||||
queries: [Query.limit(limit), Query.offset(variablesOffset)]
|
||||
}),
|
||||
listAllVariables(),
|
||||
projectSdk.vcs.listInstallations({
|
||||
queries: [Query.limit(limit), Query.offset(offset)]
|
||||
})
|
||||
]);
|
||||
@@ -19,7 +45,9 @@ export const load: PageLoad = async ({ depends, url, params }) => {
|
||||
return {
|
||||
limit,
|
||||
offset,
|
||||
variablesOffset,
|
||||
variables,
|
||||
allVariables,
|
||||
installations
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { CardGrid, Empty, Output, PaginationInline } from '$lib/components';
|
||||
import UploadVariables from './uploadVariablesModal.svelte';
|
||||
import { variablesOperation, type VariablesOperationItem } from './variablesOperation';
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
@@ -43,6 +44,7 @@
|
||||
|
||||
export let project: Models.Project;
|
||||
export let variableList: Models.VariableList;
|
||||
export let allVariableList: Models.VariableList | undefined = undefined;
|
||||
export let globalVariableList: Models.VariableList | undefined = undefined;
|
||||
export let analyticsSource = '';
|
||||
export let isGlobal: boolean;
|
||||
@@ -59,6 +61,9 @@
|
||||
) => Promise<unknown>;
|
||||
export let sdkDeleteVariable: (variableId: string) => Promise<unknown>;
|
||||
export let product: 'function' | 'site' = 'function';
|
||||
export let backendPagination = false;
|
||||
export let variablesOffset = 0;
|
||||
export let variablesLimit = 10;
|
||||
|
||||
let selectedVar: Models.Variable = null;
|
||||
let showVariablesUpload = false;
|
||||
@@ -71,6 +76,9 @@
|
||||
let deleteError: string;
|
||||
let offset = 0;
|
||||
const limit = 10;
|
||||
function handleVariablesImportStatus(detail: VariablesOperationItem) {
|
||||
variablesOperation.set(detail);
|
||||
}
|
||||
|
||||
async function handleVariableCreated(event: CustomEvent<Models.Variable[]>) {
|
||||
const variables = event.detail;
|
||||
@@ -140,10 +148,25 @@
|
||||
}
|
||||
|
||||
async function handleVariableDeleted() {
|
||||
const deleteId = selectedVar.$id;
|
||||
|
||||
try {
|
||||
variablesOperation.set({
|
||||
id: deleteId,
|
||||
count: 1,
|
||||
mode: 'delete',
|
||||
status: 'deleting'
|
||||
});
|
||||
|
||||
await sdkDeleteVariable(selectedVar.$id);
|
||||
showDeleteModal = false;
|
||||
selectedVar = null;
|
||||
variablesOperation.set({
|
||||
id: deleteId,
|
||||
count: 1,
|
||||
mode: 'delete',
|
||||
status: 'completed'
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${project.name} ${
|
||||
@@ -153,6 +176,13 @@
|
||||
trackEvent(Submit.VariableDelete);
|
||||
} catch (error) {
|
||||
deleteError = error.message;
|
||||
variablesOperation.set({
|
||||
id: deleteId,
|
||||
count: 1,
|
||||
mode: 'delete',
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
});
|
||||
trackError(error, Submit.VariableDelete);
|
||||
}
|
||||
}
|
||||
@@ -260,8 +290,13 @@
|
||||
})
|
||||
: [];
|
||||
|
||||
$: editorVariableList = allVariableList ?? variableList;
|
||||
$: displayedVariables = backendPagination
|
||||
? variableList.variables
|
||||
: variableList.variables.slice(offset, offset + limit);
|
||||
|
||||
$: hasConflictOnPage = globalVariableList
|
||||
? variableList.variables.slice(offset, offset + limit).filter((variable) => {
|
||||
? displayedVariables.filter((variable) => {
|
||||
return globalVariableList.variables.find((globalVariable) => {
|
||||
return variable.key === globalVariable.key;
|
||||
});
|
||||
@@ -279,6 +314,17 @@
|
||||
{ id: 'value', width: { min: 200, max: 400 } },
|
||||
{ id: 'actions', width: 50 }
|
||||
];
|
||||
|
||||
async function handleVariablesPageChange() {
|
||||
const nextUrl = new URL(page.url);
|
||||
|
||||
nextUrl.searchParams.set('variablesOffset', String(variablesOffset));
|
||||
|
||||
await goto(nextUrl, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<CardGrid>
|
||||
@@ -364,7 +410,7 @@
|
||||
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
|
||||
<Table.Header.Cell column="actions" {root} />
|
||||
</svelte:fragment>
|
||||
{#each variableList.variables.slice(offset, offset + limit) as variable}
|
||||
{#each displayedVariables as variable}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="key" {root}>
|
||||
{@const isConflicting = globalVariableList
|
||||
@@ -461,10 +507,19 @@
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{#if sum > limit}
|
||||
{#if sum > (backendPagination ? variablesLimit : limit)}
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
<p class="text">Total variables: {sum}</p>
|
||||
<PaginationInline total={sum} {limit} bind:offset hidePages />
|
||||
{#if backendPagination}
|
||||
<PaginationInline
|
||||
total={sum}
|
||||
limit={variablesLimit}
|
||||
bind:offset={variablesOffset}
|
||||
hidePages
|
||||
on:change={handleVariablesPageChange} />
|
||||
{:else}
|
||||
<PaginationInline total={sum} {limit} bind:offset hidePages />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
@@ -504,7 +559,7 @@
|
||||
{sdkCreateVariable}
|
||||
{sdkUpdateVariable}
|
||||
{sdkDeleteVariable}
|
||||
{variableList}
|
||||
variableList={editorVariableList}
|
||||
bind:showEditor={showEditorModal} />
|
||||
{/if}
|
||||
|
||||
@@ -524,8 +579,9 @@
|
||||
<UploadVariables
|
||||
{sdkCreateVariable}
|
||||
{sdkUpdateVariable}
|
||||
{variableList}
|
||||
bind:show={showVariablesUpload} />
|
||||
variableList={editorVariableList}
|
||||
bind:show={showVariablesUpload}
|
||||
onStatusChange={handleVariablesImportStatus} />
|
||||
{/if}
|
||||
|
||||
{#if showDeleteModal}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { parse } from '$lib/helpers/envfile';
|
||||
import { removeFile } from '$lib/helpers/files';
|
||||
import type { VariablesOperationItem } from './variablesOperation';
|
||||
|
||||
export let show = false;
|
||||
export let variableList: Models.VariableList;
|
||||
@@ -30,10 +31,12 @@
|
||||
value: string,
|
||||
secret?: boolean
|
||||
) => Promise<unknown>;
|
||||
export let onStatusChange: (detail: VariablesOperationItem) => void = () => {};
|
||||
|
||||
let files: FileList;
|
||||
let secret = false;
|
||||
let error: string;
|
||||
let isSubmitting = false;
|
||||
$: filesList = files?.length
|
||||
? Array.from(files).map((file) => ({
|
||||
...file,
|
||||
@@ -45,6 +48,9 @@
|
||||
: [];
|
||||
|
||||
async function handleSubmit() {
|
||||
let importId = '';
|
||||
let uploadCount = 0;
|
||||
|
||||
try {
|
||||
if (!files?.length) {
|
||||
throw new Error('No file selected');
|
||||
@@ -64,27 +70,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries = entries.filter(([, value]) => !!value);
|
||||
|
||||
if (!filteredEntries.length) {
|
||||
throw new Error('No variables found');
|
||||
}
|
||||
|
||||
if (filteredEntries.length > 100) {
|
||||
throw new Error('Please upload a file with fewer than 100 environment variables.');
|
||||
}
|
||||
|
||||
importId = crypto.randomUUID();
|
||||
uploadCount = filteredEntries.length;
|
||||
isSubmitting = true;
|
||||
|
||||
onStatusChange({
|
||||
id: importId,
|
||||
count: uploadCount,
|
||||
mode: 'import',
|
||||
status: 'uploading'
|
||||
});
|
||||
|
||||
show = false;
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => {
|
||||
const found = variableList.variables.find(
|
||||
(variable) => variable.key === key
|
||||
);
|
||||
return found
|
||||
? sdkUpdateVariable(found.$id, key, value, secret)
|
||||
: sdkCreateVariable(key, value, secret);
|
||||
})
|
||||
filteredEntries.map(([key, value]) => {
|
||||
const found = variableList.variables.find((variable) => variable.key === key);
|
||||
return found
|
||||
? sdkUpdateVariable(found.$id, key, value, secret)
|
||||
: sdkCreateVariable(key, value, secret);
|
||||
})
|
||||
);
|
||||
|
||||
onStatusChange({
|
||||
id: importId,
|
||||
count: uploadCount,
|
||||
mode: 'import',
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `Variables have been uploaded.`
|
||||
});
|
||||
|
||||
show = false;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
|
||||
if (importId) {
|
||||
onStatusChange({
|
||||
id: importId,
|
||||
count: uploadCount,
|
||||
mode: 'import',
|
||||
status: 'failed',
|
||||
error
|
||||
});
|
||||
}
|
||||
|
||||
if (!show) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -138,7 +187,9 @@
|
||||
bind:checked={secret}
|
||||
description="If selected, you and your team won't be able to read the values after creation." />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button text on:click={() => (show = false)}>Cancel</Button>
|
||||
<Button submit disabled={!files?.length}>Import</Button>
|
||||
<Button text on:click={() => (show = false)} disabled={isSubmitting}>Cancel</Button>
|
||||
<Button submit disabled={!files?.length || isSubmitting}>
|
||||
{isSubmitting ? 'Uploading...' : 'Import'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { Link } from '$lib/elements';
|
||||
import { Modal, Code } from '$lib/components';
|
||||
import PaginationInline from '$lib/components/paginationInline.svelte';
|
||||
import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { variablesOperation, type VariablesOperationItem } from './variablesOperation';
|
||||
|
||||
let isOpen = true;
|
||||
let showDetails = false;
|
||||
let offset = 0;
|
||||
|
||||
$: if (!$variablesOperation.length) {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
$: if (offset >= $variablesOperation.length) {
|
||||
offset = Math.max(0, $variablesOperation.length - 1);
|
||||
}
|
||||
|
||||
$: currentItem = $variablesOperation[offset] ?? null;
|
||||
|
||||
function graphSize(status: VariablesOperationItem['status']): number {
|
||||
switch (status) {
|
||||
case 'uploading':
|
||||
case 'deleting':
|
||||
return 60;
|
||||
case 'completed':
|
||||
case 'failed':
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
function text({ status, count, mode }: VariablesOperationItem): string {
|
||||
const label = `${count} variable${count === 1 ? '' : 's'}`;
|
||||
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return mode === 'delete' ? `${label} deleted` : `${label} uploaded`;
|
||||
case 'failed':
|
||||
return mode === 'delete' ? `Variable deletion failed` : `Variable import failed`;
|
||||
case 'deleting':
|
||||
return `Deleting ${label}...`;
|
||||
case 'uploading':
|
||||
return `Uploading ${label}...`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if currentItem}
|
||||
<Layout.Stack direction="column" gap="l" alignItems="flex-end">
|
||||
<section class="upload-box">
|
||||
<header class="upload-box-header">
|
||||
<h4 class="upload-box-title">
|
||||
<Typography.Text variant="m-500">
|
||||
{currentItem.mode === 'delete'
|
||||
? 'Deleting variables'
|
||||
: 'Importing variables'}
|
||||
</Typography.Text>
|
||||
</h4>
|
||||
<button
|
||||
class="upload-box-button"
|
||||
class:is-open={isOpen}
|
||||
aria-label="toggle variables import box"
|
||||
onclick={() => (isOpen = !isOpen)}>
|
||||
<span class="icon-cheveron-up" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
class="upload-box-button"
|
||||
aria-label="close variables import box"
|
||||
onclick={() => variablesOperation.clear(currentItem.id)}>
|
||||
<span class="icon-x" aria-hidden="true"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="upload-box-content-list">
|
||||
<div class="upload-box-content" class:is-open={isOpen}>
|
||||
<ul class="upload-box-list">
|
||||
<li class="upload-box-item">
|
||||
<section class="progress-bar u-width-full-line">
|
||||
<div
|
||||
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
|
||||
<Typography.Text>{text(currentItem)}</Typography.Text>
|
||||
</div>
|
||||
<div
|
||||
class="progress-bar-container"
|
||||
class:is-danger={currentItem.status === 'failed'}
|
||||
style="--graph-size:{graphSize(currentItem.status)}%">
|
||||
</div>
|
||||
{#if currentItem.status === 'failed'}
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
gap="xs"
|
||||
alignItems="center"
|
||||
inline>
|
||||
<Icon
|
||||
icon={IconExclamationCircle}
|
||||
color="--fgcolor-error"
|
||||
size="s" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
There was an issue
|
||||
{currentItem.mode === 'delete'
|
||||
? ' deleting'
|
||||
: ' importing'}
|
||||
variables.
|
||||
{#if currentItem.error}
|
||||
<Link
|
||||
style="color: inherit"
|
||||
onclick={() => (showDetails = true)}>
|
||||
View details
|
||||
</Link>
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</section>
|
||||
</li>
|
||||
</ul>
|
||||
{#if $variablesOperation.length > 1}
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
class="upload-box-pagination">
|
||||
<Typography.Caption variant="400">
|
||||
{offset + 1} of {$variablesOperation.length}
|
||||
</Typography.Caption>
|
||||
<PaginationInline
|
||||
total={$variablesOperation.length}
|
||||
limit={1}
|
||||
bind:offset
|
||||
hidePages />
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
|
||||
<Modal title="Import error" bind:show={showDetails} hideFooter>
|
||||
<Layout.Stack gap="m">
|
||||
<Code language="sh" code={currentItem?.error ?? ''} withCopy allowScroll />
|
||||
</Layout.Stack>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.upload-box {
|
||||
display: flex;
|
||||
max-height: 320px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upload-box-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.upload-box-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.upload-box-content-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.upload-box-content {
|
||||
width: 324px;
|
||||
}
|
||||
|
||||
.upload-box-pagination {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.upload-box-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
height: 4px;
|
||||
|
||||
&::before {
|
||||
height: 4px;
|
||||
background-color: var(--bgcolor-neutral-invert);
|
||||
}
|
||||
|
||||
&.is-danger::before {
|
||||
height: 4px;
|
||||
background-color: var(--bgcolor-error);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type VariablesOperationItem = {
|
||||
id: string;
|
||||
count: number;
|
||||
mode: 'import' | 'delete';
|
||||
status: 'uploading' | 'deleting' | 'completed' | 'failed';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function createVariablesOperation() {
|
||||
const { subscribe, update, set } = writable<VariablesOperationItem[]>([]);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set: (item: VariablesOperationItem) =>
|
||||
update((items) => {
|
||||
const index = items.findIndex((current) => current.id === item.id);
|
||||
|
||||
if (index !== -1) {
|
||||
const next = [...items];
|
||||
next[index] = item;
|
||||
return next;
|
||||
}
|
||||
|
||||
return [item, ...items];
|
||||
}),
|
||||
clear: (id?: string) =>
|
||||
update((items) => {
|
||||
if (!id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.filter((item) => item.id !== id);
|
||||
}),
|
||||
reset: () => set([])
|
||||
};
|
||||
}
|
||||
|
||||
export const variablesOperation = createVariablesOperation();
|
||||
Reference in New Issue
Block a user