refactor(gui): extract the share dialogs' pure logic into tested modules

This commit is contained in:
Juan Castro
2026-08-18 16:48:25 -04:00
parent ab1be0f4de
commit f4e64eea23
5 changed files with 227 additions and 25 deletions
+47
View File
@@ -0,0 +1,47 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// The initial-and-color avatar the share modal shows next to each person.
/**
* Stable, name-derived hue so a person keeps the same color across reopenings.
*
* @param {string} [name]
* @returns {number} hue in [0, 360)
*/
export const avatarHue = (name) => {
let hue = 0;
for ( const char of String(name ?? '') ) {
hue = (hue * 31 + char.codePointAt(0)) % 360;
}
return hue;
};
/**
* The name's first character, uppercased; `?` when there is no name.
*
* @param {string} [name]
* @returns {string}
*/
export const avatarInitial = (name) => {
const trimmed = String(name ?? '').trim();
// Iterating takes a whole code point; charAt(0) halves an emoji into tofu.
for ( const char of trimmed ) return char.toUpperCase();
return '?';
};
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { avatarHue, avatarInitial } from './shareAvatar.js';
describe('avatarHue', () => {
it('gives the same person the same color every time', () => {
expect(avatarHue('juan')).toBe(avatarHue('juan'));
});
it('stays a usable hue', () => {
for ( const name of ['a', 'juan', 'someone@example.com', '𝒥'.repeat(40)] ) {
const hue = avatarHue(name);
expect(Number.isInteger(hue)).toBe(true);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
}
});
it('tells different people apart', () => {
expect(avatarHue('ann')).not.toBe(avatarHue('bob'));
});
it('handles a missing name instead of throwing', () => {
expect(avatarHue(undefined)).toBe(0);
expect(avatarHue(null)).toBe(0);
expect(avatarHue('')).toBe(0);
});
});
describe('avatarInitial', () => {
it('uppercases the first letter', () => {
expect(avatarInitial('juan')).toBe('J');
expect(avatarInitial('Ann')).toBe('A');
});
it('ignores surrounding whitespace', () => {
expect(avatarInitial(' ann ')).toBe('A');
});
it('falls back to ? when there is no name', () => {
// A pending invite has no username until the recipient joins.
expect(avatarInitial('')).toBe('?');
expect(avatarInitial(' ')).toBe('?');
expect(avatarInitial(undefined)).toBe('?');
expect(avatarInitial(null)).toBe('?');
});
it('keeps an astral first character whole', () => {
// charAt(0) would return half a surrogate pair and render as tofu.
expect(avatarInitial('😀 team')).toBe('😀');
});
});
+1 -25
View File
@@ -23,31 +23,7 @@ import path from '../lib/path.js';
import { owner_of_path } from '../helpers/path_owner.js';
import { invalidate_shared_roots } from '../helpers/shared_access.js';
import { icons } from '../helpers/actionIcons.js';
// Offered when granting. The API accepts `see` and `list` too, but they are a
// developer-level distinction with no place in this dialog — a row already set
// to one is shown as-is rather than quietly rounded up to `read`.
const MODES = ['read', 'write', 'manage'];
// Already HTML-safe: `i18n()` encodes what it returns, and an unencoded mode
// from the API is encoded here. Encoding a label again turns the `&` in
// "Can edit & share" into a literal `&amp;`.
const mode_label = (mode) => {
if ( mode === 'write' ) return i18n('share_access_write');
if ( mode === 'manage' ) return i18n('share_access_manage');
if ( mode === 'read' ) return i18n('share_access_read');
return html_encode(mode);
};
const options_for = (current) => {
const modes = MODES.includes(current) ? MODES : [current, ...MODES];
return modes
.map(
(mode) =>
`<option value="${html_encode(mode)}"${mode === current ? ' selected' : ''}>${mode_label(mode)}</option>`,
)
.join('');
};
import { mode_label, options_for } from '../helpers/share_modes.js';
/**
* Sharing dialog for one file or directory.
+58
View File
@@ -0,0 +1,58 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Access levels the sharing dialogs offer, shared so the two can't drift.
// The API also accepts `see` and `list`, a developer-level distinction with no
// place in these dialogs; a row already set to one is shown as-is.
export const MODES = ['read', 'write', 'manage'];
/**
* Human-readable label for an access mode, ready to drop into HTML.
*
* Already HTML-safe — encoding it again renders the `&` in "Can edit & share".
*
* @param {string} mode
* @returns {string} HTML-safe label
*/
export const mode_label = (mode) => {
if ( mode === 'write' ) return i18n('share_access_write');
if ( mode === 'manage' ) return i18n('share_access_manage');
if ( mode === 'read' ) return i18n('share_access_read');
return html_encode(mode);
};
/**
* `<option>` markup for a mode `<select>`, with `current` selected.
*
* A mode outside `MODES` is listed first rather than dropped, so opening a
* dialog on a `see`/`list` grant doesn't silently rewrite it.
*
* @param {string} current
* @returns {string} HTML-safe markup
*/
export const options_for = (current) => {
const modes = MODES.includes(current) ? MODES : [current, ...MODES];
return modes
.map(
(mode) =>
`<option value="${html_encode(mode)}"${mode === current ? ' selected' : ''}>${mode_label(mode)}</option>`,
)
.join('');
};
+70
View File
@@ -0,0 +1,70 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { decode, encode } from 'html-entities';
// Runs the real `i18n` and the real encoder, like UIPermissionDialog.rendering.test.js:
// a stubbed i18n echoes keys and cannot tell single- from double-encoding.
globalThis.window = globalThis.window ?? {};
globalThis.html_encode = (str) => encode(str);
let MODES, mode_label, options_for;
beforeAll(async () => {
await import('../i18n/i18n.js'); // installs window.i18n
globalThis.i18n = window.i18n;
({ MODES, mode_label, options_for } = await import('./share_modes.js'));
});
// What the browser renders for a fragment of HTML-safe markup.
const as_text = (html) => decode(html);
describe('mode_label', () => {
it('encodes exactly once, so "&" survives as "&"', () => {
const label = mode_label('manage');
expect(label).toBe('Can edit &amp; share');
expect(as_text(label)).toBe('Can edit & share');
// The bug this guards: encoding an already-encoded label again.
expect(label).not.toContain('&amp;amp;');
});
it('labels the modes the dialogs offer', () => {
expect(as_text(mode_label('read'))).toBe('Can view');
expect(as_text(mode_label('write'))).toBe('Can edit');
});
it('shows an out-of-band mode as-is', () => {
expect(mode_label('see')).toBe('see');
expect(mode_label('list')).toBe('list');
});
it('encodes an unrecognized mode rather than trusting it', () => {
expect(mode_label('<img src=x onerror=alert(1)>')).not.toContain('<img');
});
});
describe('options_for', () => {
const values = (html) => [...html.matchAll(/value="([^"]*)"/g)].map((m) => m[1]);
it('offers the three modes with the current one selected', () => {
const html = options_for('read');
expect(values(html)).toEqual(MODES);
expect(html).toContain('<option value="read" selected>');
});
it('selects a non-default mode without reordering', () => {
const html = options_for('manage');
expect(values(html)).toEqual(MODES);
expect(html).toContain('<option value="manage" selected>');
expect(html).not.toContain('<option value="read" selected>');
});
it('keeps an out-of-band mode instead of rounding it to read', () => {
const html = options_for('see');
expect(values(html)).toEqual(['see', ...MODES]);
expect(html).toContain('<option value="see" selected>');
});
it('renders each label encoded exactly once', () => {
expect(options_for('read')).toContain('>Can edit &amp; share</option>');
expect(options_for('read')).not.toContain('&amp;amp;');
});
});