Skip to content

Commit 9b991d1

Browse files
authored
Merge pull request #1829 from appwrite/feat-project-changes
2 parents 6f19fdf + b1e87a2 commit 9b991d1

File tree

12 files changed

+356
-18
lines changed

12 files changed

+356
-18
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<script lang="ts">
2+
import { page } from '$app/state';
3+
import { Click } from '$lib/actions/analytics';
4+
import { Button } from '$lib/elements/forms';
5+
import { HeaderAlert } from '$lib/layout';
6+
import {
7+
billingProjectsLimitDate,
8+
hideBillingHeaderRoutes,
9+
upgradeURL
10+
} from '$lib/stores/billing';
11+
import { currentPlan } from '$lib/stores/organization';
12+
import { onMount } from 'svelte';
13+
import SelectProjectCloud from './selectProjectCloud.svelte';
14+
import { toLocaleDate } from '$lib/helpers/date';
15+
let showSelectProject: boolean = $state(false);
16+
let selectedProjects: string[] = $state([]);
17+
onMount(() => {
18+
selectedProjects = page.data.organization?.projects || [];
19+
});
20+
</script>
21+
22+
<SelectProjectCloud bind:showSelectProject bind:selectedProjects />
23+
24+
{#if $currentPlan && $currentPlan.projects > 0 && !hideBillingHeaderRoutes.includes(page.url.pathname)}
25+
<HeaderAlert
26+
type="warning"
27+
title="Action required: You have more than {$currentPlan.projects} projects.">
28+
<svelte:fragment>
29+
Choose which projects to keep before {toLocaleDate(billingProjectsLimitDate)} or upgrade
30+
to Pro. Projects over the limit will be blocked after this date.
31+
</svelte:fragment>
32+
<svelte:fragment slot="buttons">
33+
<Button
34+
compact
35+
on:click={() => {
36+
showSelectProject = true;
37+
}}>Manage projects</Button>
38+
<Button
39+
href={$upgradeURL}
40+
event={Click.OrganizationClickUpgrade}
41+
eventData={{
42+
from: 'button',
43+
source: 'projects_limit_banner'
44+
}}
45+
secondary
46+
fullWidthMobile>
47+
<span class="text">Upgrade</span>
48+
</Button>
49+
</svelte:fragment>
50+
</HeaderAlert>
51+
{/if}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<script lang="ts">
2+
import { type Models } from '@appwrite.io/console';
3+
import { Alert, Button, Table } from '@appwrite.io/pink-svelte';
4+
import { Modal } from '$lib/components';
5+
import { onMount } from 'svelte';
6+
import { sdk } from '$lib/stores/sdk';
7+
import { addNotification } from '$lib/stores/notifications';
8+
import { invalidate } from '$app/navigation';
9+
import { Dependencies } from '$lib/constants';
10+
import { billingProjectsLimitDate } from '$lib/stores/billing';
11+
import { page } from '$app/state';
12+
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
13+
import { currentPlan } from '$lib/stores/organization';
14+
15+
let {
16+
showSelectProject = $bindable(false),
17+
selectedProjects = $bindable([])
18+
}: {
19+
showSelectProject: boolean;
20+
selectedProjects: string[];
21+
} = $props();
22+
23+
let projects = $state<Array<Models.Project>>([]);
24+
let error = $state<string | null>(null);
25+
26+
onMount(() => {
27+
projects = page.data.projects?.projects || [];
28+
});
29+
30+
let projectsToArchive = $derived(
31+
projects.filter((project) => !selectedProjects.includes(project.$id))
32+
);
33+
34+
async function updateSelected() {
35+
try {
36+
await sdk.forConsole.billing.updateSelectedProjects(
37+
projects[0].teamId,
38+
selectedProjects
39+
);
40+
showSelectProject = false;
41+
invalidate(Dependencies.ORGANIZATION);
42+
addNotification({
43+
type: 'success',
44+
message: `Projects updated for archiving`
45+
});
46+
} catch (e) {
47+
error = e.message;
48+
}
49+
}
50+
51+
function formatProjectsToArchive() {
52+
let result = '';
53+
54+
projectsToArchive.forEach((project, index) => {
55+
const text = `${index === 0 ? '' : ' '}<b>${project.name}</b> `;
56+
result += text;
57+
58+
if (index < projectsToArchive.length - 1) {
59+
if (index == projectsToArchive.length - 2) {
60+
result += 'and ';
61+
}
62+
if (index < projectsToArchive.length - 2) {
63+
result += ', ';
64+
}
65+
}
66+
});
67+
68+
return result;
69+
}
70+
</script>
71+
72+
<Modal bind:show={showSelectProject} title={'Manage projects'} onSubmit={updateSelected}>
73+
<svelte:fragment slot="description">
74+
Choose which two projects to keep. Projects over the limit will be blocked after this date.
75+
</svelte:fragment>
76+
{#if error}
77+
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
78+
{/if}
79+
<Table.Root
80+
let:root
81+
allowSelection
82+
bind:selectedRows={selectedProjects}
83+
columns={[{ id: 'name' }, { id: 'created' }]}>
84+
<svelte:fragment slot="header" let:root>
85+
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
86+
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
87+
</svelte:fragment>
88+
{#each projects as project}
89+
<Table.Row.Base {root} id={project.$id}>
90+
<Table.Cell column="name" {root}>{project.name}</Table.Cell>
91+
<Table.Cell column="created" {root}
92+
>{toLocaleDateTime(project.$createdAt)}</Table.Cell>
93+
</Table.Row.Base>
94+
{/each}
95+
</Table.Root>
96+
{#if selectedProjects.length > $currentPlan?.projects}
97+
<div class="u-text-warning u-mb-4">
98+
You can only select {$currentPlan?.projects} projects. Please deselect others to continue.
99+
</div>
100+
{/if}
101+
{#if selectedProjects.length === $currentPlan?.projects}
102+
<Alert.Inline
103+
status="warning"
104+
title={`${projects.length - selectedProjects.length} projects will be archived on ${toLocaleDate(billingProjectsLimitDate)}`}>
105+
<span>
106+
{@html formatProjectsToArchive()}
107+
will be archived.
108+
</span>
109+
</Alert.Inline>
110+
{/if}
111+
<svelte:fragment slot="footer">
112+
<Button.Button size="s" variant="secondary" on:click={() => (showSelectProject = false)}
113+
>Cancel</Button.Button>
114+
<Button.Button size="s" disabled={selectedProjects.length !== $currentPlan?.projects}
115+
>Save</Button.Button>
116+
</svelte:fragment>
117+
</Modal>

src/lib/components/gridItem1.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<slot name="subtitle" />
1515
</div>
1616
</Layout.Stack>
17-
<Layout.Stack direction="row" justifyContent="flex-end" alignItems="center">
17+
<Layout.Stack direction="row" justifyContent="flex-end" alignItems="flex-start">
1818
<slot name="status" />
1919
</Layout.Stack>
2020
</Layout.Stack>

src/lib/layout/createProject.svelte

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,39 @@
11
<script lang="ts">
2-
import { Layout, Typography, Input, Tag, Icon } from '@appwrite.io/pink-svelte';
2+
import { Layout, Typography, Input, Tag, Icon, Alert } from '@appwrite.io/pink-svelte';
33
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
44
import { CustomId } from '$lib/components/index.js';
55
import { getFlagUrl } from '$lib/helpers/flag';
66
import { isCloud } from '$lib/system.js';
7+
import { currentPlan } from '$lib/stores/organization';
8+
import { Button } from '$lib/elements/forms';
9+
import { base } from '$app/paths';
10+
import { page } from '$app/state';
711
import type { Models } from '@appwrite.io/console';
812
import { filterRegions } from '$lib/helpers/regions';
13+
import type { Snippet } from 'svelte';
914
10-
export let projectName: string;
11-
export let id: string;
12-
export let regions: Array<Models.ConsoleRegion> = [];
13-
export let region: string;
14-
export let showTitle = true;
15+
let {
16+
projectName = $bindable(''),
17+
id = $bindable(''),
18+
regions = [],
19+
region = $bindable(''),
20+
showTitle = true,
21+
projects = undefined,
22+
submit
23+
}: {
24+
projectName: string;
25+
id: string;
26+
regions: Array<Models.ConsoleRegion>;
27+
region: string;
28+
showTitle: boolean;
29+
projects?: number;
30+
submit?: Snippet;
31+
} = $props();
1532
16-
let showCustomId = false;
33+
let showCustomId = $state(false);
34+
let projectsLimited = $derived(
35+
$currentPlan?.projects > 0 && projects && projects >= $currentPlan?.projects
36+
);
1737
</script>
1838

1939
<svelte:head>
@@ -26,10 +46,24 @@
2646
{#if showTitle}
2747
<Typography.Title size="l">Create your project</Typography.Title>
2848
{/if}
49+
{#if projectsLimited}
50+
<Alert.Inline status="warning" title="You've reached your limit of 2 projects">
51+
Extra projects are available on paid plans for an additional fee
52+
<svelte:fragment slot="actions">
53+
<Button
54+
compact
55+
size="s"
56+
href={`${base}/organization-${page.params.organization}/billing`}
57+
external
58+
text>Upgrade</Button>
59+
</svelte:fragment>
60+
</Alert.Inline>
61+
{/if}
2962
<Layout.Stack direction="column" gap="xxl">
3063
<Layout.Stack direction="column" gap="xxl">
3164
<Layout.Stack direction="column" gap="s">
3265
<Input.Text
66+
disabled={projectsLimited}
3367
label="Name"
3468
placeholder="Project name"
3569
required
@@ -49,6 +83,7 @@
4983
{#if isCloud && regions.length > 0}
5084
<Layout.Stack gap="xs">
5185
<Input.Select
86+
disabled={projectsLimited}
5287
required
5388
bind:value={region}
5489
placeholder="Select a region"
@@ -59,5 +94,5 @@
5994
{/if}
6095
</Layout.Stack>
6196
</Layout.Stack>
62-
<slot name="submit"></slot>
97+
{@render submit?.()}
6398
</Layout.Stack>

src/lib/sdk/billing.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ export type Plan = {
313313
webhooks: number;
314314
users: number;
315315
teams: number;
316+
projects: number;
316317
databases: number;
317318
databasesAllowEncrypt: boolean;
318319
buckets: number;
@@ -592,6 +593,25 @@ export class Billing {
592593
);
593594
}
594595

596+
async updateSelectedProjects(
597+
organizationId: string,
598+
projects: string[]
599+
): Promise<Organization> {
600+
const path = `/organizations/${organizationId}/projects`;
601+
const params = {
602+
projects
603+
};
604+
const uri = new URL(this.client.config.endpoint + path);
605+
return await this.client.call(
606+
'patch',
607+
uri,
608+
{
609+
'content-type': 'application/json'
610+
},
611+
params
612+
);
613+
}
614+
595615
async setOrganizationPaymentMethod(
596616
organizationId: string,
597617
paymentMethodId: string

src/lib/stores/billing.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { sdk } from './sdk';
3939
import { user } from './user';
4040
import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
4141
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
42+
import ProjectsLimit from '$lib/components/billing/alerts/projectsLimit.svelte';
4243
import EnterpriseTrial from '$routes/(console)/organization-[organization]/enterpriseTrial.svelte';
4344

4445
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1' | 'ent-1';
@@ -68,6 +69,7 @@ export const roles = [
6869

6970
export const teamStatusReadonly = 'readonly';
7071
export const billingLimitOutstandingInvoice = 'outstanding_invoice';
72+
export const billingProjectsLimitDate = '2024-09-01';
7173

7274
export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList);
7375
export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList);
@@ -315,6 +317,24 @@ export function calculateTrialDay(org: Organization) {
315317
return days;
316318
}
317319

320+
export async function checkForProjectsLimit(org: Organization, projects: number) {
321+
if (!isCloud) return;
322+
if (!org || !projects) return;
323+
const plan = await sdk.forConsole.billing.getOrganizationPlan(org.$id);
324+
if (!plan) return;
325+
if (plan.$id !== BillingPlan.FREE) return;
326+
if (org.projects?.length > 0) return;
327+
328+
if (plan.projects > 0 && projects > plan.projects) {
329+
headerAlert.add({
330+
id: 'projectsLimitReached',
331+
component: ProjectsLimit,
332+
show: true,
333+
importance: 12
334+
});
335+
}
336+
}
337+
318338
export async function checkForUsageLimit(org: Organization) {
319339
if (org?.status === teamStatusReadonly && org?.remarks === billingLimitOutstandingInvoice) {
320340
headerAlert.add({

src/lib/stores/organization.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type Organization = Models.Team<Record<string, unknown>> & {
3434
billingInvoiceId: string;
3535
status: string;
3636
remarks: string;
37+
projects: string[];
3738
};
3839

3940
export type OrganizationList = {
@@ -60,5 +61,4 @@ export const organizationList = derived(
6061
export const organization = derived(page, ($page) => $page.data?.organization as Organization);
6162
export const currentPlan = derived(page, ($page) => $page.data?.currentPlan as Plan);
6263
export const members = derived(page, ($page) => $page.data.members as Models.MembershipList);
63-
6464
export const regions = writable<Models.ConsoleRegionList>({ total: 0, regions: [] });

src/routes/(console)/+layout.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
checkForMarkedForDeletion,
1919
checkForMissingPaymentMethod,
2020
checkForNewDevUpgradePro,
21+
checkForProjectsLimit,
2122
checkForUsageLimit,
2223
checkPaymentAuthorizationRequired,
2324
paymentExpired,
@@ -296,6 +297,7 @@
296297
if (currentOrganizationId === org.$id) return;
297298
if (isCloud) {
298299
currentOrganizationId = org.$id;
300+
checkForProjectsLimit(org, data.projects?.projects?.length || 0);
299301
checkForEnterpriseTrial(org);
300302
await checkForUsageLimit(org);
301303
checkForMarkedForDeletion(org);

0 commit comments

Comments
 (0)