mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 23:17:23 +00:00
refactor(gui): extract the share dialogs' pure logic into tested modules
This commit is contained in:
@@ -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('😀');
|
||||
});
|
||||
});
|
||||
@@ -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 `&`.
|
||||
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.
|
||||
|
||||
@@ -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('');
|
||||
};
|
||||
@@ -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 & share');
|
||||
expect(as_text(label)).toBe('Can edit & share');
|
||||
// The bug this guards: encoding an already-encoded label again.
|
||||
expect(label).not.toContain('&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 & share</option>');
|
||||
expect(options_for('read')).not.toContain('&amp;');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user