Skip to content
Merged
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
Binary file modified docs/public/screenshots/create-site-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 0 additions & 17 deletions src/renderer/components/create-site-dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { __ } from '@wordpress/i18n';
import { Button, Dialog, InputControl, Notice, Stack } from '@wordpress/ui';
import { DEFAULT_PROJECT_TYPE } from '../../project-type.cjs';
import { createSiteProblem, projectChoices, projectHelp } from '../create-site.cjs';
import { directoryFromFileEntry } from '../site-folder.cjs';
import { FolderField } from './folder-field.jsx';

// The dialog's three answers and the button that sends them. It is inside
Expand All @@ -35,21 +34,6 @@ function CreateSiteForm({ formId, submitting, defaultDir, onCreate }) {
} catch {}
}, []);

// Not reached by the intended route, which is the system's dialog above:
// a folder dropped on the input arrives here.
const takeFiles = useCallback((event) => {
const input = event.target;
const files = input.files;
if (files && files.length > 0) {
const resolved = directoryFromFileEntry(files[0], input.value);
setDir(resolved);
// Clearing the error only when there is a directory: a selection that
// resolved to nothing has not fixed anything the message was about.
if (resolved) setError('');
}
input.value = '';
}, []);

const submit = (event) => {
event.preventDefault();
const problem = createSiteProblem({ name, dir });
Expand Down Expand Up @@ -91,7 +75,6 @@ function CreateSiteForm({ formId, submitting, defaultDir, onCreate }) {
value={dir}
disabled={submitting}
onChoose={chooseFolder}
onFiles={takeFiles}
/>
{error ? (
// An alert, which is said as it appears. The notice is told to
Expand Down
47 changes: 19 additions & 28 deletions src/renderer/components/folder-field.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useId } from 'react';
import { __ } from '@wordpress/i18n';
import { Field, Text } from '@wordpress/ui';
import { Button, Field, Text } from '@wordpress/ui';

/**
* A field whose answer is a folder (#557, #559).
*
* The field is a file input, which is what says "choose a folder" without a
* word, and the folder it holds is said under it: the app asks the system
* for a folder itself, since a page is not told where one is, and the input
* is never left holding a selection. The create-site dialog and the settings
* both ask with it.
* The field is a button that asks the system for a folder, since a page is
* not told where one is, and the folder it holds is said under it. Not a
* file input: Chromium draws that one's button and its "No file chosen" in
* its own language, which is not always the window's, and a folder dropped
* on it never had a path to give (#228, #655). The create-site dialog and
* the settings both ask with it.
*
* @param {Object} props
* @param {string} props.label What the folder is for.
Expand All @@ -17,34 +19,23 @@ import { Field, Text } from '@wordpress/ui';
* @param {string} [props.empty] What to say in place of a folder when there is none.
* @param {boolean} [props.disabled]
* @param {Function} props.onChoose Asked to open the system's dialog.
* @param {Function} [props.onFiles] A folder dropped on the input, which arrives as files.
*/
export function FolderField({ label, description, value, empty, disabled = false, onChoose, onFiles }) {
export function FolderField({ label, description, value, empty, disabled = false, onChoose }) {
const labelId = useId();
const textId = useId();
// Named by the label and by what the button says, so "Choose folder" is
// in the name a voice control user would say (#655), and described by the
// folder it holds, so the folder is heard where the button is.
return (
<Field.Root className="folder-field" disabled={disabled}>
<Field.Label>{label}</Field.Label>
<Field.Label><span id={labelId}>{label}</span></Field.Label>
<Field.Control
className="file-field-control"
render={
<input
type="file"
webkitdirectory=""
// eslint-disable-next-line react/no-unknown-property -- non-standard but required alongside webkitdirectory for cross-browser directory pickers.
directory=""
multiple
/>
}
render={<Button variant="outline" tone="neutral" className="file-field-control"><span id={textId}>{__('Choose folder…')}</span></Button>}
aria-labelledby={`${labelId} ${textId}`}
disabled={disabled}
onChange={onFiles}
onClick={(event) => { event.preventDefault(); onChoose(); }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onChoose();
}
}}
onClick={onChoose}
/>
<Text variant="body-sm" className="file-field-value">{value || empty || __('No folder selected yet.')}</Text>
<Field.Description render={<Text variant="body-sm" className="file-field-value" />}>{value || empty || __('No folder selected yet.')}</Field.Description>
{description ? <Field.Description>{description}</Field.Description> : null}
</Field.Root>
);
Expand Down
17 changes: 3 additions & 14 deletions src/renderer/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -1310,21 +1310,10 @@ body {
}

/* A folder field (#557, #559), in the create-site dialog and the settings.
The folder is asked for with a file input, as wide as the dialog, and the
folder that was chosen is said under it: a path has no spaces to break
at. */
The folder that was chosen is said under its button: a path has no
spaces to break at. The button keeps its own width, not the field's. */
.folder-field .file-field-control {
width: 100%;
min-height: var(--wpds-dimension-size-md);
font: inherit;
color: inherit;
}

/* The ring the design system's own controls have, where the browser would
draw its own. */
.folder-field .file-field-control:focus-visible {
outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus);
outline-offset: var(--wpds-border-width-focus);
align-self: flex-start;
}

.folder-field .file-field-value {
Expand Down
50 changes: 3 additions & 47 deletions src/renderer/site-folder.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Where a new site goes, and what its folder is called.
//
// Three decisions the Create site modal makes before `setupWordPress` is ever
// called, all of them string work on paths the renderer cannot hand to Node's
// Two decisions the Create site modal makes before `setupWordPress` is ever
// called, both of them string work on paths the renderer cannot hand to Node's
// `path` module: it has none. The chosen root arrives in the platform's native
// form — `C:\Users\me` on Windows, `/Users/me` elsewhere — so joining a folder
// name onto it means picking the separator by looking at the string.
Expand Down Expand Up @@ -53,48 +53,4 @@ function resolveTargetDir(root, folder) {
return `${normalizedRoot}${separator}${folder}`;
}

/**
* The directory an `<input type="file" webkitdirectory>` selection points at.
*
* Nothing reaches this by the intended route: the input's click and keyboard
* handlers are intercepted and go to the native dialog. It runs only when a
* folder is dropped onto the control — a route the app deliberately does not
* support, decided in #228 and closed there. Extracted as it stood, dead branch
* included, because #216 is a refactor and not the place to change what it
* answers:
*
* - `path` plus `webkitRelativePath`, and `path` alone, are the two shapes this
* was written for. Electron removed the `path` augmentation on `File` in v32
* in favour of `webUtils.getPathForFile`; this app pins Electron 43 and
* bridges no `webUtils`, so neither branch is reachable.
* - What is left is the input's own `value`, which is a fiction: a file input's
* value is empty or the literal `C:\fakepath\` prefix on every platform. So a
* dropped folder resolves to '' or to `C:\fakepath`, and the modal presents
* the second as a real destination.
*
* @param {*} file The first entry of the input's `files` list.
* @param {*} inputValue The input's `value`, read only when `file` has no path.
* @return {string} The directory without a trailing separator, or '' when none
* could be derived.
*/
function directoryFromFileEntry(file, inputValue) {
const relative = file?.webkitRelativePath || '';
const rawPath = file?.path || '';
let resolved = '';

if (rawPath) {
if (relative) {
resolved = rawPath.slice(0, rawPath.length - relative.length);
} else {
resolved = rawPath.replace(/[\\/][^\\/]*$/, '');
}
}

if (!resolved && inputValue) {
resolved = String(inputValue).replace(/[^\\/]*$/, '');
}

return resolved.replace(/[\\/]+$/, '');
}

module.exports = { sanitizeSiteFolder, resolveTargetDir, directoryFromFileEntry, FALLBACK_FOLDER };
module.exports = { sanitizeSiteFolder, resolveTargetDir, FALLBACK_FOLDER };
7 changes: 6 additions & 1 deletion tests/e2e/journeys/create-site.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ test( 'the create-site dialog refuses a missing name or location, starts clean e

const dialog = ui.createSiteDialog( page );
const name = dialog.getByLabel( 'Site name', { exact: true } );
const location = dialog.getByLabel( 'Location', { exact: true } );
const location = dialog.getByRole( 'button', { name: 'Location Choose folder…', exact: true } );
const core = dialog.getByRole( 'radio', { name: 'WordPress Core', exact: true } );
const gutenberg = dialog.getByRole( 'radio', { name: 'Gutenberg', exact: true } );
const create = dialog.getByRole( 'button', { name: 'Create site', exact: true } );
Expand All @@ -64,6 +64,10 @@ test( 'the create-site dialog refuses a missing name or location, starts clean e
const projects = dialog.getByRole( 'radiogroup', { name: 'Project', exact: true } );
await expect( projects ).toHaveAccessibleDescription( `${ projectHelp( 'core' ).about } ${ projectHelp( 'core' ).lasting }` );
expect( projectHelp( 'core' ).lasting ).toBe( 'A site’s project cannot be changed later.' );
// The folder is said where its button is, before what the field is for
// (#655).
const LOCATION_HELP = 'Choose the parent folder where you want this new site created. A new subdirectory will be created for the site.';
await expect( location ).toHaveAccessibleDescription( `No folder selected yet. ${ LOCATION_HELP }` );

// INVARIANT — it says which answer is missing, one at a time, as an
// alert, and starts nothing while one is: a name of spaces is no name.
Expand All @@ -80,6 +84,7 @@ test( 'the create-site dialog refuses a missing name or location, starts clean e
// about it away.
await location.press( 'Enter' );
await expect( dialog.getByText( parent, { exact: true } ) ).toBeVisible();
await expect( location ).toHaveAccessibleDescription( `${ parent } ${ LOCATION_HELP }` );
await expect( dialog.getByText( 'Please choose where to create the site.', { exact: true } ) ).toHaveCount( 0 );
await gutenberg.click();
await expect( gutenberg ).toBeChecked();
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/journeys/i18n.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ test( 'the first-run screen and the create-site dialog are fully translatable',
await expect( dialog ).toBeVisible();
await expect( dialog.getByRole( 'button', { name: /^\[/ } ).first() ).toBeVisible();
expect( await unwrapped( dialog ) ).toEqual( [] );
// The folder's button is the app's own, not a file input, whose button
// and "No file chosen" Chromium draws in its own language, where the scan
// cannot see them (#655).
await expect( dialog.getByRole( 'button', { name: `${ pseudoLocalize( 'Location' ) } ${ pseudoLocalize( 'Choose folder…' ) }`, exact: true } ) ).toHaveText( pseudoLocalize( 'Choose folder…' ) );

// A validation error is wrapped too.
await dialog.getByRole( 'button', { name: pseudoLocalize( 'Create site' ), exact: true } ).click();
Expand All @@ -116,6 +120,7 @@ test( 'the settings dialog is fully translatable, on both of its tabs', async (
await expect( dialog ).toBeVisible();
await expect( dialog.getByText( pseudoLocalize( 'Not set: the create-site dialog asks each time.' ), { exact: true } ) ).toBeVisible();
expect( await unwrapped( dialog ) ).toEqual( [] );
await expect( dialog.getByRole( 'button', { name: `${ pseudoLocalize( 'New sites go here' ) } ${ pseudoLocalize( 'Choose folder…' ) }`, exact: true } ) ).toHaveText( pseudoLocalize( 'Choose folder…' ) );

// The Sites tab, once it has the PHP versions: their numbers are not
// words and stay as they are.
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/journeys/settings.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ test( 'the folder new sites go in is chosen in the settings, used by the create-

// INVARIANT — a folder chosen is shown, kept, and offered to be forgotten.
await session.answerFileDialog( [ parent ] );
const field = dialog.getByLabel( 'New sites go here', { exact: true } );
const field = dialog.getByRole( 'button', { name: 'New sites go here Choose folder…', exact: true } );
await field.press( 'Enter' );
await expect( dialog.getByText( parent, { exact: true } ) ).toBeVisible();
await expect( notSet ).toHaveCount( 0 );
Expand All @@ -67,7 +67,7 @@ test( 'the folder new sites go in is chosen in the settings, used by the create-
await expect( create.getByText( 'No folder selected yet.', { exact: true } ) ).toHaveCount( 0 );
const other = session.track( fs.mkdtempSync( path.join( os.tmpdir(), 'wpct-e2e-other-' ) ) );
await session.answerFileDialog( [ other ] );
await create.getByLabel( 'Location', { exact: true } ).press( 'Enter' );
await create.getByRole( 'button', { name: 'Location Choose folder…', exact: true } ).press( 'Enter' );
await expect( create.getByText( other, { exact: true } ) ).toBeVisible();
await ui.closeDialogButton( create ).click();
await expect( create ).toHaveCount( 0 );
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/real-setup/create-site.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ for ( const target of TARGETS ) {
await name.fill( 'real-setup' );
await expect( name ).toHaveValue( 'real-setup', { timeout: 2_000 } );
} ).toPass( { timeout: 30_000 } );
await modal.getByLabel( 'Location', { exact: true } ).press( 'Enter' );
await modal.getByRole( 'button', { name: 'Location Choose folder…', exact: true } ).press( 'Enter' );
await modal.getByRole( 'button', { name: 'Create site', exact: true } ).click();
} );

Expand Down
36 changes: 0 additions & 36 deletions tests/unit/site-folder.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const assert = require('node:assert/strict');
const {
sanitizeSiteFolder,
resolveTargetDir,
directoryFromFileEntry,
FALLBACK_FOLDER
} = require('../../src/renderer/site-folder.cjs');

Expand Down Expand Up @@ -64,38 +63,3 @@ test('resolveTargetDir with no root is the folder name alone', () => {
assert.equal(resolveTargetDir('', 'my-site'), 'my-site');
assert.equal(resolveTargetDir(null, 'my-site'), 'my-site');
});

// What follows pins what this function does with the entries it actually gets,
// which is not the same as what it was written for. The `path` property it
// prefers was removed from `File` in Electron 32, this app pins Electron 43,
// and no `webUtils` bridge replaces it — so every real entry takes the
// fallback. Asserting the `path` shapes would be green and prove nothing.
//
// The only route that reaches this at all is dropping a folder on the control,
// which the app deliberately does not support — #228, closed as not planned.
// So these record where an unsupported route ends, and are the tests a change
// of mind would have to rewrite.

test('directoryFromFileEntry gets nothing from a real dropped entry', () => {
// A File in Electron 43. No `path`, and `webkitRelativePath` alone carries
// no absolute part to cut it off.
assert.equal(directoryFromFileEntry({ webkitRelativePath: 'sites/inner/file.txt' }, ''), '');
assert.equal(directoryFromFileEntry({}, ''), '');
assert.equal(directoryFromFileEntry(null, ''), '');
assert.equal(directoryFromFileEntry(undefined, undefined), '');
});

test('directoryFromFileEntry passes C:\\fakepath through — #228', () => {
// Recorded, not endorsed. A file input's `value` is either empty or this
// literal prefix on every platform, browsers substituting it for the real
// path, so the fallback's "typed path" is a fiction. The modal shows this
// as the chosen folder and submit hands it to setup.
assert.equal(directoryFromFileEntry({}, 'C:\\fakepath\\my-folder'), 'C:\\fakepath');
});

test('directoryFromFileEntry returns nothing rather than a wrong directory', () => {
// '' is what the caller checks before it clears the chosen directory — a
// bare segment is not a directory anyone chose.
assert.equal(directoryFromFileEntry({}, 'file.txt'), '');
assert.equal(directoryFromFileEntry({}, ''), '');
});
Loading