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
41 changes: 36 additions & 5 deletions apps/mobile/app/organization-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ const openPhotoSettings = () => {

export default function OrganizationSettingsScreen() {
const auth = useAuth();
return (
<OrganizationSettingsForAccount
key={`${auth.userId ?? ""}:${auth.bootstrap?.activeOrganizationId ?? ""}`}
/>
);
}

function OrganizationSettingsForAccount() {
const auth = useAuth();
const activeOrganizationId = auth.bootstrap?.activeOrganizationId;
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const [settings, setSettings] = useState<MobileOrganizationSettings | null>(
null,
);
Expand All @@ -86,6 +103,13 @@ export default function OrganizationSettingsScreen() {
.getOrganizationSettings()
.then((response) => {
if (requestRef.current !== requestId) return;
if (activeOrganizationId && response.id !== activeOrganizationId) {
setSettings(null);
setError(
"The active organization changed. Refresh your account and try again.",
);
return;
}
setSettings(response);
setName(response.name);
setAllowedEmailDomain(response.allowedEmailDomain ?? "");
Expand All @@ -98,7 +122,7 @@ export default function OrganizationSettingsScreen() {
.finally(() => {
if (requestRef.current === requestId) setLoading(false);
});
}, [auth.client, auth.status]);
}, [activeOrganizationId, auth.client, auth.status]);

useEffect(() => {
load();
Expand All @@ -114,14 +138,16 @@ export default function OrganizationSettingsScreen() {
const busy = saving || iconBusy;

const save = async () => {
if (!settings?.canManage || saving || !name.trim()) return;
if (!activeRef.current || !settings?.canManage || saving || !name.trim())
return;
setSaving(true);
setError(null);
try {
const response = await auth.client.updateOrganizationSettings({
name: name.trim(),
allowedEmailDomain: allowedEmailDomain.trim() || null,
});
if (!activeRef.current) return;
setSettings(response);
setName(response.name);
setAllowedEmailDomain(response.allowedEmailDomain ?? "");
Expand All @@ -134,11 +160,12 @@ export default function OrganizationSettingsScreen() {
};

const chooseIcon = async () => {
if (!settings?.canManage || busy) return;
if (!activeRef.current || !settings?.canManage || busy) return;
let result: ImagePicker.ImagePickerResult;
try {
const permission =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!activeRef.current) return;
if (!permission.granted) {
openPhotoSettings();
return;
Expand All @@ -151,10 +178,11 @@ export default function OrganizationSettingsScreen() {
quality: 0.8,
});
} catch {
if (!activeRef.current) return;
setError("Photos could not be opened. Check access and try again.");
return;
}
if (result.canceled) return;
if (!activeRef.current || result.canceled) return;
const asset = result.assets[0];
if (!asset?.base64) {
setError("The selected image could not be read. Choose another image.");
Expand Down Expand Up @@ -191,6 +219,7 @@ export default function OrganizationSettingsScreen() {
contentType,
fileName,
});
if (!activeRef.current) return;
setSettings(response);
void auth.refresh().catch(() => undefined);
} catch (cause) {
Expand All @@ -201,11 +230,13 @@ export default function OrganizationSettingsScreen() {
};

const removeIcon = async () => {
if (!settings?.canManage || !settings.iconUrl || busy) return;
if (!activeRef.current || !settings?.canManage || !settings.iconUrl || busy)
return;
setIconBusy(true);
setError(null);
try {
const response = await auth.client.removeOrganizationIcon();
if (!activeRef.current) return;
setSettings(response);
void auth.refresh().catch(() => undefined);
} catch (cause) {
Expand Down
184 changes: 184 additions & 0 deletions apps/mobile/src/screens/organization-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const settings = {
const auth = vi.hoisted(() => ({
value: {
status: "signedIn" as const,
bootstrap: { activeOrganizationId: "org_123" },
client: {
getOrganizationSettings: vi.fn(),
removeOrganizationIcon: vi.fn(),
Expand Down Expand Up @@ -167,6 +168,7 @@ vi.mock("@/components/CapLoadingIndicator", async () => {

describe("OrganizationSettingsScreen", () => {
beforeEach(() => {
auth.value.bootstrap.activeOrganizationId = "org_123";
auth.value.client.getOrganizationSettings.mockReset();
auth.value.client.getOrganizationSettings.mockResolvedValue(settings);
auth.value.client.updateOrganizationSettings.mockReset();
Expand Down Expand Up @@ -219,4 +221,186 @@ describe("OrganizationSettingsScreen", () => {
});
expect(auth.value.refresh).toHaveBeenCalledTimes(1);
});

it("replaces drafts when the active organization changes", async () => {
const renderer = await renderComponent(
React.createElement(OrganizationSettingsScreen),
);
const input = renderer.root.findByProps({
accessibilityLabel: "Organization name",
});
await act(async () => input.props.onChangeText("Unfinished A"));
auth.value.client.getOrganizationSettings.mockResolvedValueOnce({
...settings,
id: "org_b",
name: "Organization B",
});
auth.value.bootstrap.activeOrganizationId = "org_b";
await act(async () => {
renderer.update(React.createElement(OrganizationSettingsScreen));
await Promise.resolve();
await Promise.resolve();
});
expect(
renderer.root.findByProps({ accessibilityLabel: "Organization name" })
.props.value,
).toBe("Organization B");
await act(async () => renderer.unmount());
});

it("ignores an old settings save after switching organizations", async () => {
let resolveSave!: (value: typeof settings) => void;
auth.value.client.updateOrganizationSettings.mockReturnValueOnce(
new Promise((resolve) => {
resolveSave = resolve;
}),
);
const renderer = await renderComponent(
React.createElement(OrganizationSettingsScreen),
);
await act(async () =>
renderer.root
.findByProps({ accessibilityLabel: "Organization name" })
.props.onChangeText("A draft"),
);
await act(async () => {
renderer.root
.findByProps({ accessibilityLabel: "Save changes" })
.props.onPress();
});
auth.value.client.getOrganizationSettings.mockResolvedValueOnce({
...settings,
id: "org_b",
name: "Organization B",
});
auth.value.bootstrap.activeOrganizationId = "org_b";
await act(async () => {
renderer.update(React.createElement(OrganizationSettingsScreen));
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
resolveSave({ ...settings, name: "A saved" });
await Promise.resolve();
});
expect(
renderer.root.findByProps({ accessibilityLabel: "Organization name" })
.props.value,
).toBe("Organization B");
await act(async () => renderer.unmount());
});

it("does not upload a photo selected for a previous organization", async () => {
const { ActionSheetIOS } = await import("react-native");
const picker = await import("expo-image-picker");
let finishPicker!: (
value: Awaited<ReturnType<typeof picker.launchImageLibraryAsync>>,
) => void;
vi.mocked(picker.requestMediaLibraryPermissionsAsync).mockResolvedValue({
granted: true,
} as Awaited<
ReturnType<typeof picker.requestMediaLibraryPermissionsAsync>
>);
vi.mocked(picker.launchImageLibraryAsync).mockReturnValueOnce(
new Promise((resolve) => {
finishPicker = resolve;
}),
);
auth.value.client.updateOrganizationIcon
.mockReset()
.mockResolvedValue(settings);
const renderer = await renderComponent(
React.createElement(OrganizationSettingsScreen),
);
await act(async () => {
renderer.root
.findByProps({ accessibilityLabel: "Organization icon" })
.props.onPress();
});
const choose = vi
.mocked(ActionSheetIOS.showActionSheetWithOptions)
.mock.calls.at(-1)?.[1];
if (!choose) throw new Error("Missing icon action");
await act(async () => {
choose(0);
await Promise.resolve();
});
auth.value.client.getOrganizationSettings.mockResolvedValueOnce({
...settings,
id: "org_b",
name: "Organization B",
});
auth.value.bootstrap.activeOrganizationId = "org_b";
await act(async () => {
renderer.update(React.createElement(OrganizationSettingsScreen));
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
finishPicker({
canceled: false,
assets: [
{
uri: "file:///photo.jpg",
width: 10,
height: 10,
base64: "abcd",
mimeType: "image/jpeg",
},
],
});
await Promise.resolve();
});
expect(auth.value.client.updateOrganizationIcon).not.toHaveBeenCalled();
await act(async () => renderer.unmount());
});
it("refuses to show editable settings for a different server organization", async () => {
auth.value.client.getOrganizationSettings.mockResolvedValueOnce({
...settings,
id: "org_b",
name: "Organization B",
});
const renderer = await renderComponent(
React.createElement(OrganizationSettingsScreen),
);
expect(getTextNodes(renderer.toJSON()).join(" ")).toContain(
"The active organization changed",
);
expect(
renderer.root.findAllByProps({ accessibilityLabel: "Organization name" }),
).toHaveLength(0);
await act(async () => renderer.unmount());
});

it("ignores a previous organization's delayed icon action", async () => {
const { ActionSheetIOS } = await import("react-native");
const picker = await import("expo-image-picker");
vi.mocked(picker.requestMediaLibraryPermissionsAsync).mockReset();
const renderer = await renderComponent(
React.createElement(OrganizationSettingsScreen),
);
await act(async () =>
renderer.root
.findByProps({ accessibilityLabel: "Organization icon" })
.props.onPress(),
);
const choose = vi
.mocked(ActionSheetIOS.showActionSheetWithOptions)
.mock.calls.at(-1)?.[1];
if (!choose) throw new Error("Missing icon action");
auth.value.client.getOrganizationSettings.mockResolvedValueOnce({
...settings,
id: "org_b",
name: "Organization B",
});
auth.value.bootstrap.activeOrganizationId = "org_b";
await act(async () => {
renderer.update(React.createElement(OrganizationSettingsScreen));
await Promise.resolve();
await Promise.resolve();
});
await act(async () => choose(0));
expect(picker.requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled();
await act(async () => renderer.unmount());
});
});
Loading