Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 14 additions & 17 deletions frontend/src/lib/modals/address-book/AddAddressModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
const disableSave = $derived(
nickname === "" ||
address === "" ||
$addressBookStore.certified !== true ||
nonNullish(nicknameError) ||
nonNullish(addressError) ||
$busy ||
Expand All @@ -120,6 +121,10 @@
event.preventDefault();
normalizeNickname();

if ($addressBookStore.certified !== true) {
return;
}

// Check if fields are empty after normalizing
if (nickname === "" || address === "") {
return;
Expand All @@ -144,29 +149,21 @@
address: addressType,
};

// Create temporary array with the updated addresses
const currentAddresses = $addressBookStore.namedAddresses ?? [];
let updatedAddresses: NamedAddress[];

if (isEditMode) {
// In edit mode, find and replace the existing entry
updatedAddresses = currentAddresses.map((entry) =>
normalizeName(entry.name) === normalizeName(namedAddress?.name ?? "")
? updatedAddress
: entry
);
} else {
// In add mode, append the new address
updatedAddresses = [...currentAddresses, updatedAddress];
}

const initiator = isEditMode
? "edit-address-book-entry"
: "add-address-book-entry";
startBusy({ initiator });

try {
const result = await saveAddressBook(updatedAddresses);
const result = await saveAddressBook(
isEditMode
? {
type: "update",
previousName: namedAddress?.name ?? "",
address: updatedAddress,
}
: { type: "add", address: updatedAddress }
);

if (!result?.err) {
toastsSuccess({
Expand Down
18 changes: 10 additions & 8 deletions frontend/src/lib/modals/address-book/RemoveAddressModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import { toastsSuccess } from "$lib/stores/toasts.store";
import { replacePlaceholders } from "$lib/utils/i18n.utils";
import { IconErrorOutline } from "@dfinity/gix-components";
import { isNullish } from "@dfinity/utils";

interface Props {
onClose: () => void;
Expand All @@ -18,19 +17,18 @@
const { onClose, namedAddress }: Props = $props();

const handleDeleteConfirm = async () => {
if (isNullish($addressBookStore.namedAddresses)) {
if ($addressBookStore.certified !== true) {
return;
}

const updatedAddresses = $addressBookStore.namedAddresses.filter(
(entry) => entry.name !== namedAddress.name
);

const initiator = "delete-address-book-entry";
startBusy({ initiator });

try {
const result = await saveAddressBook(updatedAddresses);
const result = await saveAddressBook({
type: "remove",
name: namedAddress.name,
});

if (!result?.err) {
toastsSuccess({
Expand All @@ -47,7 +45,11 @@
};
</script>

<ConfirmationModal on:nnsClose={onClose} on:nnsConfirm={handleDeleteConfirm}>
<ConfirmationModal
on:nnsClose={onClose}
on:nnsConfirm={handleDeleteConfirm}
disabledConfirm={$addressBookStore.certified !== true}
>
<div data-tid="remove-address-confirmation" class="wrapper">
<h4>
{replacePlaceholders($i18n.address_book.remove_address_title, {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/modals/common/ConfirmationModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

export let testId = "confirmation-modal-component";
export let yesLabel: string | undefined = undefined;
export let disabledConfirm = false;

const dispatch = createEventDispatcher();

Expand All @@ -27,7 +28,7 @@
</button>
<button
data-tid="confirm-yes"
disabled={$busy}
disabled={$busy || disabledConfirm}
class="primary"
on:click={() => dispatch("nnsConfirm")}
>
Expand Down
60 changes: 54 additions & 6 deletions frontend/src/lib/services/address-book.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,66 @@ export const loadAddressBook = async ({
});
};

export type AddressBookMutation =
| { type: "add"; address: NamedAddress }
| { type: "update"; previousName: string; address: NamedAddress }
| { type: "remove"; name: string };

const normalizeName = (name: string): string => name.trim().toLowerCase();

const applyMutation = ({
namedAddresses,
mutation,
}: {
namedAddresses: NamedAddress[];
mutation: AddressBookMutation;
}): NamedAddress[] => {
switch (mutation.type) {
case "add":
return [...namedAddresses, mutation.address];
case "update": {
const previousName = normalizeName(mutation.previousName);
const addressExists = namedAddresses.some(
({ name }) => normalizeName(name) === previousName
);
if (!addressExists) {
throw new Error("The address book entry no longer exists.");
}
return namedAddresses.map((address) =>
normalizeName(address.name) === previousName
? mutation.address
: address
);
}
case "remove": {
const addresses = namedAddresses.filter(
({ name }) => name !== mutation.name
);
if (addresses.length === namedAddresses.length) {
throw new Error("The address book entry no longer exists.");
}
return addresses;
}
Comment on lines +101 to +109
}
};

/**
* Save the entire address book to the `nns-dapp` backend and reload to update the `addressBookStore`.
* - This method always saves the complete address book (replaces the existing one).
* - The UI is responsible for manipulating the array (add/update/remove) before calling this method.
* - Displays appropriate error toasts based on the error type.
* - Returns an error if the operation fails.
* Applies one mutation to a certified address book and reloads the store.
* Uncertified store data is never used as the base of the backend write.
*/
export const saveAddressBook = async (
namedAddresses: NamedAddress[]
mutation: AddressBookMutation
): Promise<{ err?: Error } | undefined> => {
try {
const identity = await getAuthenticatedIdentity();
const { named_addresses: certifiedAddresses } = await getAddressBook({
identity,
certified: true,
});
const namedAddresses = applyMutation({
namedAddresses: certifiedAddresses,
mutation,
});
await setAddressBook({ identity, namedAddresses });
await loadAddressBook();
Comment on lines 130 to 131
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("AddAddressModal", () => {

beforeEach(() => {
vi.clearAllMocks();
addressBookStore.reset();
addressBookStore.set({ namedAddresses: [], certified: true });
});

it("should display modal", async () => {
Expand Down Expand Up @@ -54,6 +54,24 @@ describe("AddAddressModal", () => {
expect(saveButton?.hasAttribute("disabled")).toBe(true);
});

it("should disable save while the address book is uncertified", async () => {
addressBookStore.set({ namedAddresses: [], certified: false });

const { container, queryByTestId } = await renderModal({
component: AddAddressModal,
props: { onClose: vi.fn() },
});

await fireEvent.input(container.querySelector("input[name='nickname']"), {
target: { value: "MyAddress" },
});
await fireEvent.input(container.querySelector("input[name='address']"), {
target: { value: validIcpAddress },
});

expect(queryByTestId("save-address-button")).toBeDisabled();
});

it("should display error if nickname is too short on submit", async () => {
const { container, queryByText, queryByTestId } = await renderModal({
component: AddAddressModal,
Expand Down Expand Up @@ -312,12 +330,13 @@ describe("AddAddressModal", () => {
const saveButton = queryByTestId("save-address-button");
await fireEvent.click(saveButton);

expect(saveAddressBookSpy).toHaveBeenCalledWith([
{
expect(saveAddressBookSpy).toHaveBeenCalledWith({
type: "add",
address: {
name: "MyAddress",
address: { Icp: validIcpAddress },
},
]);
});

expect(onClose).toHaveBeenCalled();
});
Expand Down Expand Up @@ -350,12 +369,13 @@ describe("AddAddressModal", () => {
const saveButton = queryByTestId("save-address-button");
await fireEvent.click(saveButton);

expect(saveAddressBookSpy).toHaveBeenCalledWith([
{
expect(saveAddressBookSpy).toHaveBeenCalledWith({
type: "add",
address: {
name: "MyICRC1",
address: { Icrc1: validIcrc1Address },
},
]);
});

expect(onClose).toHaveBeenCalled();
});
Expand Down Expand Up @@ -389,13 +409,13 @@ describe("AddAddressModal", () => {
const saveButton = queryByTestId("save-address-button");
await fireEvent.click(saveButton);

expect(saveAddressBookSpy).toHaveBeenCalledWith([
mockNamedAddressIcp,
{
expect(saveAddressBookSpy).toHaveBeenCalledWith({
type: "add",
address: {
name: "NewAddress",
address: { Icrc1: validIcrc1Address },
},
]);
});
});

it("should not close modal on error", async () => {
Expand Down Expand Up @@ -680,12 +700,14 @@ describe("AddAddressModal", () => {
const saveButton = queryByTestId("save-address-button");
await fireEvent.click(saveButton);

expect(saveAddressBookSpy).toHaveBeenCalledWith([
{
expect(saveAddressBookSpy).toHaveBeenCalledWith({
type: "update",
previousName: mockNamedAddressIcp.name,
address: {
name: "UpdatedNickname",
address: mockNamedAddressIcp.address,
},
]);
});

expect(onClose).toHaveBeenCalled();
});
Expand Down Expand Up @@ -718,13 +740,14 @@ describe("AddAddressModal", () => {
const saveButton = queryByTestId("save-address-button");
await fireEvent.click(saveButton);

expect(saveAddressBookSpy).toHaveBeenCalledWith([
{
expect(saveAddressBookSpy).toHaveBeenCalledWith({
type: "update",
previousName: mockNamedAddressIcp.name,
address: {
name: mockNamedAddressIcp.name,
address: { Icrc1: validIcrc1Address },
},
mockNamedAddressIcrc1,
]);
});
});
});

Expand Down Expand Up @@ -791,12 +814,13 @@ describe("AddAddressModal", () => {
const saveButton = queryByTestId("save-address-button");
await fireEvent.click(saveButton);

expect(saveAddressBookSpy).toHaveBeenCalledWith([
{
expect(saveAddressBookSpy).toHaveBeenCalledWith({
type: "add",
address: {
name: "My Test Address",
address: { Icp: validIcpAddress },
},
]);
});

expect(onClose).toHaveBeenCalled();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import RemoveAddressModal from "$lib/modals/address-book/RemoveAddressModal.svelte";
import * as addressBookServices from "$lib/services/address-book.services";
import { addressBookStore } from "$lib/stores/address-book.store";
import { mockNamedAddressIcp } from "$tests/mocks/address-book.mock";
import { renderModal } from "$tests/mocks/modal.mock";
import { fireEvent } from "@testing-library/svelte";

vi.mock("$lib/services/address-book.services");

describe("RemoveAddressModal", () => {
beforeEach(() => {
vi.clearAllMocks();
addressBookStore.set({
namedAddresses: [mockNamedAddressIcp],
certified: true,
});
});

it("removes an address through a mutation", async () => {
const saveAddressBookSpy = vi
.spyOn(addressBookServices, "saveAddressBook")
.mockResolvedValue({});
const onClose = vi.fn();
const { queryByTestId } = await renderModal({
component: RemoveAddressModal,
props: { namedAddress: mockNamedAddressIcp, onClose },
});

await fireEvent.click(queryByTestId("confirm-yes"));

expect(saveAddressBookSpy).toHaveBeenCalledWith({
type: "remove",
name: mockNamedAddressIcp.name,
});
expect(onClose).toHaveBeenCalled();
});

it("disables removal while the address book is uncertified", async () => {
addressBookStore.set({
namedAddresses: [mockNamedAddressIcp],
certified: false,
});
const saveAddressBookSpy = vi.spyOn(addressBookServices, "saveAddressBook");
const { queryByTestId } = await renderModal({
component: RemoveAddressModal,
props: { namedAddress: mockNamedAddressIcp, onClose: vi.fn() },
});

expect(queryByTestId("confirm-yes")).toBeDisabled();
expect(saveAddressBookSpy).not.toHaveBeenCalled();
});
});
Loading
Loading