mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6e1e15892 | ||
|
|
9c9d4edccd |
No files matched your search
@@ -40,7 +40,9 @@ let feedback_modal_seq = 0;
|
||||
* server is the authority on the app's canonical title — then submits to
|
||||
* `POST /app-feedback`.
|
||||
*
|
||||
* @param {{ app?: string, origin?: string, source?: 'app' | 'web' }} options
|
||||
* @param {{ app?: string, origin?: string, source?: 'app' | 'web', fullpage?: boolean }} options
|
||||
* `fullpage` fills the viewport instead of floating over it, for a popup
|
||||
* window that exists only to host this dialog.
|
||||
* @returns {Promise<boolean>} true iff feedback was submitted successfully.
|
||||
* Resolves false on cancel/close/unavailable — never rejects, so IPC and
|
||||
* popup callers can always report an answer.
|
||||
@@ -71,7 +73,7 @@ async function UIWindowAppFeedback (options) {
|
||||
|
||||
const titleId = `${modal_id}-title`;
|
||||
const h = `
|
||||
<div class="app-feedback-overlay" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-overlay${options.fullpage ? ' app-feedback-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-modal" tabindex="-1">
|
||||
<div class="app-feedback-head">
|
||||
<h2 class="app-feedback-title" id="${titleId}">${i18n('app_feedback_title')}</h2>
|
||||
@@ -80,16 +82,16 @@ async function UIWindowAppFeedback (options) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-feedback-body">
|
||||
<div class="app-feedback-loading">${i18n('loading')}…</div>
|
||||
<div class="app-feedback-pane app-feedback-loading">${i18n('loading')}…</div>
|
||||
|
||||
<div class="app-feedback-unavailable" style="display:none;">
|
||||
<div class="app-feedback-pane app-feedback-unavailable" style="display:none;">
|
||||
<p class="app-feedback-unavailable-message"></p>
|
||||
<div class="app-feedback-actions">
|
||||
<button type="button" class="app-feedback-btn app-feedback-btn-primary app-feedback-close-btn">${i18n('close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-feedback-form" style="display:none;">
|
||||
<div class="app-feedback-pane app-feedback-form" style="display:none;">
|
||||
<div class="app-feedback-target">
|
||||
<div class="app-feedback-target-title"></div>
|
||||
<div class="app-feedback-target-name"></div>
|
||||
@@ -105,7 +107,7 @@ async function UIWindowAppFeedback (options) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-feedback-success" style="display:none;">
|
||||
<div class="app-feedback-pane app-feedback-success" style="display:none;">
|
||||
<div class="app-feedback-success-check" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const { default: UIWindowAppFeedback } = await import('./UIWindowAppFeedback.js');
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
const $ = (sel) => jQuery(sel);
|
||||
const visiblePanes = () => $('.app-feedback-pane').filter((_, el) => el.style.display !== 'none');
|
||||
|
||||
/** Opens the dialog against an app that accepts feedback and waits for the form. */
|
||||
const open = async (options) => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
app: { title: 'Build with AI', name: 'builder' },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
UIWindowAppFeedback({ origin: 'https://builder.puter.test', source: 'web', ...options });
|
||||
await flush();
|
||||
await flush();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'token';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
$(document).off('keydown');
|
||||
});
|
||||
|
||||
describe('UIWindowAppFeedback', () => {
|
||||
it('floats over the page by default', async () => {
|
||||
await open();
|
||||
expect($('.app-feedback-overlay').hasClass('app-feedback-fullpage')).toBe(false);
|
||||
expect(visiblePanes().hasClass('app-feedback-form')).toBe(true);
|
||||
});
|
||||
|
||||
it('fills the page when hosted in its own popup', async () => {
|
||||
await open({ fullpage: true });
|
||||
expect($('.app-feedback-overlay').hasClass('app-feedback-fullpage')).toBe(true);
|
||||
expect(visiblePanes()).toHaveLength(1);
|
||||
expect(visiblePanes().hasClass('app-feedback-form')).toBe(true);
|
||||
expect($('.app-feedback-target-title').text()).toBe('Build with AI');
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
const titleId = `${modal_id}-title`;
|
||||
const closeLabel = html_encode(i18n('close'));
|
||||
const h = `
|
||||
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' custom-domain-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-overlay custom-domain-overlay${options.fullpage ? ' app-feedback-fullpage' : ''}" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
|
||||
<div class="app-feedback-modal custom-domain-modal" tabindex="-1">
|
||||
<div class="app-feedback-head custom-domain-head">
|
||||
<div class="custom-domain-head-icon">${ICON_GLOBE}</div>
|
||||
@@ -95,13 +95,13 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
<button type="button" class="app-feedback-x" aria-label="${closeLabel}" title="${closeLabel}">${ICON_X}</button>
|
||||
</div>
|
||||
<div class="app-feedback-body custom-domain-body">
|
||||
<div class="custom-domain-pane" data-pane="loading" aria-busy="true">
|
||||
<div class="app-feedback-pane custom-domain-pane" data-pane="loading" aria-busy="true">
|
||||
<span class="custom-domain-sr">${i18n('loading')}…</span>
|
||||
<div class="custom-domain-skeleton"><span></span><span></span></div>
|
||||
<div class="custom-domain-skeleton"><span></span><span></span></div>
|
||||
</div>
|
||||
|
||||
<div class="custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
|
||||
<div class="app-feedback-pane custom-domain-pane custom-domain-state" data-pane="unavailable" style="display:none;">
|
||||
<div class="custom-domain-state-icon">${ICON_ALERT}</div>
|
||||
<p class="app-feedback-unavailable-message"></p>
|
||||
<div class="app-feedback-actions">
|
||||
@@ -109,7 +109,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-domain-pane" data-pane="list" style="display:none;">
|
||||
<div class="app-feedback-pane custom-domain-pane" data-pane="list" style="display:none;">
|
||||
<ul class="custom-domain-list"></ul>
|
||||
<div class="custom-domain-state custom-domain-empty" style="display:none;">
|
||||
<div class="custom-domain-state-icon">${ICON_GLOBE}</div>
|
||||
@@ -124,7 +124,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="custom-domain-pane" data-pane="add" style="display:none;" novalidate>
|
||||
<form class="app-feedback-pane custom-domain-pane" data-pane="add" style="display:none;" novalidate>
|
||||
<label class="custom-domain-label" for="${modal_id}-hostname">${i18n('custom_domain_hostname_label')}</label>
|
||||
<div class="custom-domain-field">
|
||||
${ICON_GLOBE}
|
||||
@@ -143,7 +143,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="custom-domain-pane" data-pane="records" style="display:none;">
|
||||
<div class="app-feedback-pane custom-domain-pane" data-pane="records" style="display:none;">
|
||||
<div class="custom-domain-status-card">
|
||||
<div class="custom-domain-status-icon"></div>
|
||||
<div class="custom-domain-status-main">
|
||||
@@ -165,7 +165,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
|
||||
<div class="app-feedback-pane custom-domain-pane app-feedback-success custom-domain-success" data-pane="success" role="status" style="display:none;">
|
||||
<div class="app-feedback-success-check">${ICON_CHECK}</div>
|
||||
<p class="app-feedback-success-text custom-domain-success-text"></p>
|
||||
<p class="app-feedback-note custom-domain-success-note"></p>
|
||||
|
||||
@@ -2739,15 +2739,17 @@ input.myapps-group-name:focus {
|
||||
}
|
||||
}
|
||||
|
||||
/* In a popup that exists only for this dialog, the dialog is the page: a
|
||||
centered column, with the actions pinned to the bottom edge. */
|
||||
.custom-domain-fullpage {
|
||||
--cd-column: min(552px, 100% - 48px);
|
||||
/* In a popup that exists only to host one of these dialogs, the dialog is the
|
||||
page: a centered column, with the actions pinned to the bottom edge. */
|
||||
.app-feedback-fullpage {
|
||||
--afb-column: min(552px, 100% - 48px);
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
background: var(--afb-bg);
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-modal {
|
||||
.app-feedback-fullpage .app-feedback-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
@@ -2758,39 +2760,39 @@ input.myapps-group-name:focus {
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.custom-domain-fullpage .app-feedback-x {
|
||||
.app-feedback-fullpage .app-feedback-x {
|
||||
display: none;
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-head {
|
||||
width: var(--cd-column);
|
||||
.app-feedback-fullpage .app-feedback-head {
|
||||
width: var(--afb-column);
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 4px;
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-body {
|
||||
.app-feedback-fullpage .app-feedback-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-pane {
|
||||
.app-feedback-fullpage .app-feedback-pane {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
|
||||
.app-feedback-fullpage .app-feedback-pane > :not(.app-feedback-actions, .app-feedback-success-check, .custom-domain-state-icon) {
|
||||
box-sizing: border-box;
|
||||
width: var(--cd-column);
|
||||
width: var(--afb-column);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
/* Keeps the actions off the content when it fills the page. */
|
||||
.custom-domain-fullpage .custom-domain-pane::after {
|
||||
.app-feedback-fullpage .app-feedback-pane::after {
|
||||
content: '';
|
||||
flex: 1 0 20px;
|
||||
order: 1;
|
||||
}
|
||||
.custom-domain-fullpage .app-feedback-actions {
|
||||
.app-feedback-fullpage .app-feedback-actions {
|
||||
order: 2;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
@@ -2799,9 +2801,18 @@ input.myapps-group-name:focus {
|
||||
background: var(--afb-bg);
|
||||
border-top: 1px solid var(--afb-border);
|
||||
}
|
||||
.custom-domain-fullpage .custom-domain-state .app-feedback-actions {
|
||||
.app-feedback-fullpage .app-feedback-unavailable .app-feedback-actions,
|
||||
.app-feedback-fullpage .custom-domain-state .app-feedback-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
/* The message box takes the spare height instead of the spacer. */
|
||||
.app-feedback-fullpage .app-feedback-form::after {
|
||||
flex-grow: 0;
|
||||
}
|
||||
.app-feedback-fullpage .app-feedback-message {
|
||||
flex: 1 0 160px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
|
||||
rows in the same modal shell. min-width gives the two-line rows room without
|
||||
|
||||
@@ -959,6 +959,7 @@ const postAuthActions = async (action) => {
|
||||
sent = await UIWindowAppFeedback({
|
||||
origin,
|
||||
source: 'web',
|
||||
fullpage: window.embedded_in_popup === true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('send-feedback action failed', e);
|
||||
|
||||
@@ -540,8 +540,9 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
else this._closePanels();
|
||||
});
|
||||
this.$('.sign-in').addEventListener('click', () => this._signIn());
|
||||
this.$('.avatar').addEventListener('click', () => {
|
||||
if ( this.$('.menu').hidden ) this._openMenu();
|
||||
this.$('.avatar').addEventListener('click', (event) => {
|
||||
// detail is 0 for a click from Enter or Space.
|
||||
if ( this.$('.menu').hidden ) this._openMenu({ fromKeyboard: event.detail === 0 });
|
||||
else this._closePanels();
|
||||
});
|
||||
for ( const link of MENU_LINKS ) {
|
||||
@@ -697,10 +698,15 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
panel.style.setProperty('--caret-x', `${Math.round(Math.min(width - CARET_INSET, Math.max(CARET_INSET, caret)))}px`);
|
||||
}
|
||||
|
||||
_openMenu () {
|
||||
/**
|
||||
* Opened from the keyboard, focus goes to the first item. Opened with the
|
||||
* pointer, it goes to the menu itself, where no item looks focused, so the
|
||||
* first ArrowDown lands on the first item.
|
||||
*/
|
||||
_openMenu ({ fromKeyboard = false } = {}) {
|
||||
const menu = this.$('.menu');
|
||||
this._showPanel(menu);
|
||||
this.$('.menu-item').focus({ preventScroll: true });
|
||||
(fromKeyboard ? this.$('.menu-item') : menu).focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/** Moves focus between menu items for an arrow, Home or End key; false for any other key. */
|
||||
|
||||
@@ -253,6 +253,18 @@ describe('<puter-user-menu>', () => {
|
||||
expect(shown(el, '.menu')).toBe(true);
|
||||
});
|
||||
|
||||
it('lands the first ArrowDown on the first item when the menu was opened with the pointer', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
part(el, '.avatar').dispatchEvent(new MouseEvent('click', { detail: 1, bubbles: true, composed: true }));
|
||||
expect(shown(el, '.menu')).toBe(true);
|
||||
expect(el.shadowRoot.activeElement).toBe(part(el, '.menu'));
|
||||
|
||||
const press = (key) => el.shadowRoot.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
|
||||
press('ArrowDown');
|
||||
expect(el.shadowRoot.activeElement.classList[1]).toBe('manage-account');
|
||||
});
|
||||
|
||||
it('hangs the panel under its button with the caret pointing at it', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
|
||||
Reference in new issue
Block a user