@gopherium/react-auth 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +65 -7
  2. package/dist/account.d.ts +3 -0
  3. package/dist/account.js +3 -0
  4. package/dist/admin/errors.d.ts +62 -0
  5. package/dist/admin/errors.js +63 -0
  6. package/dist/admin/fieldError.d.ts +19 -0
  7. package/dist/admin/fieldError.js +46 -0
  8. package/dist/admin/index.d.ts +10 -37
  9. package/dist/admin/index.js +88 -94
  10. package/dist/languages/es-ES.json +1 -1
  11. package/dist/languages/fr-FR.json +1 -0
  12. package/dist/languages/languages/es-ES.json +1 -1
  13. package/dist/languages/languages/fr-FR.json +1 -0
  14. package/dist/languages.js +1 -0
  15. package/dist/testing/index.d.ts +9 -0
  16. package/dist/testing/index.js +21 -0
  17. package/dist/testing/setup.js +2 -0
  18. package/dist/testing/users.d.ts +91 -0
  19. package/dist/testing/users.js +84 -0
  20. package/dist/transport.d.ts +1 -0
  21. package/dist/wpds/PasswordField.d.ts +21 -0
  22. package/dist/wpds/PasswordField.js +130 -0
  23. package/dist/wpds/SetupAccountStep.js +17 -177
  24. package/dist/wpds/index.d.ts +4 -3
  25. package/dist/wpds/index.js +3 -3
  26. package/dist/wpds/nav.d.ts +2 -5
  27. package/dist/wpds/nav.js +4 -8
  28. package/dist/wpds/style.css +11 -22
  29. package/dist/wpds/usePasswordField.d.ts +37 -0
  30. package/dist/wpds/usePasswordField.js +86 -0
  31. package/dist/wpds/users/ChangeRoleDialog.d.ts +35 -0
  32. package/dist/wpds/users/ChangeRoleDialog.js +90 -0
  33. package/dist/wpds/users/ResendModal.d.ts +13 -0
  34. package/dist/wpds/users/ResendModal.js +60 -0
  35. package/dist/wpds/users/RoleSelect.d.ts +20 -0
  36. package/dist/wpds/users/RoleSelect.js +18 -0
  37. package/dist/wpds/users/UsersPage.d.ts +34 -0
  38. package/dist/wpds/users/UsersPage.js +91 -0
  39. package/dist/wpds/users/actions.d.ts +31 -0
  40. package/dist/wpds/users/actions.js +79 -0
  41. package/dist/wpds/users/defaults.d.ts +14 -0
  42. package/dist/wpds/users/defaults.js +17 -0
  43. package/dist/wpds/users/fields.d.ts +40 -0
  44. package/dist/wpds/users/fields.js +172 -0
  45. package/dist/wpds/users/index.d.ts +9 -0
  46. package/dist/wpds/users/index.js +5 -0
  47. package/dist/wpds/users/words.d.ts +47 -0
  48. package/dist/wpds/users/words.js +151 -0
  49. package/package.json +38 -1
  50. package/dist/wpds/NewUserScreen.d.ts +0 -9
  51. package/dist/wpds/NewUserScreen.js +0 -70
  52. package/dist/wpds/UsersScreen.d.ts +0 -11
  53. package/dist/wpds/UsersScreen.js +0 -46
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { ConfirmBody, markLatest, useCloseWhileOpen, useToaster } from '@gopherium/godmin';
4
+ import { useMutation } from '@tanstack/react-query';
5
+ import { __, sprintf } from '@wordpress/i18n';
6
+ import { Button, InputControl, Stack, Text } from '@wordpress/ui';
7
+ import { useEffect, useState } from 'react';
8
+ import { resendInvite } from '../../admin/index.js';
9
+ import { DOMAIN } from '../../domain.js';
10
+ import { errorMessageOf } from './words.js';
11
+ /**
12
+ * Renders the activation link to deliver by hand when no mail server sent the invitation.
13
+ * @param props - The link and what closes the dialog.
14
+ * @returns The link, read only, above Done.
15
+ */
16
+ function HandDelivery({ link, onDone }) {
17
+ return (_jsxs(Stack, { direction: "column", gap: "lg", children: [_jsx(Text, { children: __('No mail server is configured. Deliver the activation link by hand.', DOMAIN) }), _jsx(InputControl, { label: __('Activation link', DOMAIN), readOnly: true, value: link }), _jsx(Stack, { direction: "row", justify: "flex-end", children: _jsx(Button, { onClick: onDone, children: __('Done', DOMAIN) }) })] }));
18
+ }
19
+ /**
20
+ * Renders the confirm sending a pending account a new invitation, closing only itself once the write ends.
21
+ * @param props - The account, what closes the dialog, the kept activation links and the app's own error words.
22
+ * @returns The confirm, or the activation link the server answered, kept for the next open if the dialog closed first.
23
+ */
24
+ export function ResendModal({ items: [account], closeModal, links, errorMessages, }) {
25
+ const toaster = useToaster();
26
+ const close = useCloseWhileOpen(closeModal);
27
+ const [link, setLink] = useState(() => links.get(account.id));
28
+ const resend = useMutation({
29
+ mutationFn: () => resendInvite(account.email),
30
+ onMutate: () => markLatest(links, account.id),
31
+ onSuccess: (sent, _, stillLatest) => {
32
+ if (sent.delivered) {
33
+ toaster.show(sprintf(__('Invitation sent to "%(name)s".', DOMAIN), { name: account.name }));
34
+ close();
35
+ return;
36
+ }
37
+ if (stillLatest()) {
38
+ links.set(account.id, sent.activation_link);
39
+ }
40
+ setLink(sent.activation_link);
41
+ },
42
+ onError: (error) => {
43
+ toaster.show(errorMessageOf(error, errorMessages, __('The invitation could not be sent.', DOMAIN)));
44
+ close();
45
+ },
46
+ });
47
+ useEffect(() => {
48
+ if (link === undefined) {
49
+ return undefined;
50
+ }
51
+ return () => {
52
+ links.delete(account.id);
53
+ };
54
+ }, [link, links, account.id]);
55
+ if (link !== undefined) {
56
+ return _jsx(HandDelivery, { link: link, onDone: closeModal });
57
+ }
58
+ const question = __('Send a new invitation to "%(name)s" (%(email)s)? The link sent before stops working.', DOMAIN);
59
+ return (_jsx(ConfirmBody, { confirmLabel: __('Resend invitation', DOMAIN), cancelLabel: __('Cancel', DOMAIN), busy: resend.isPending, onConfirm: () => resend.mutate(), onCancel: closeModal, children: sprintf(question, { name: account.name, email: account.email }) }));
60
+ }
@@ -0,0 +1,20 @@
1
+ import type { RoleLabels } from './words.js';
2
+ /** What the role menu takes, all data. */
3
+ export interface RoleSelectProps {
4
+ /** The roles the reader may give, the one carrying most capabilities first, as the session lists them. */
5
+ grantable: readonly string[];
6
+ /** The role chosen, empty for none. */
7
+ value: string;
8
+ /** Takes the role the reader picks, empty for the empty first option. */
9
+ onChange: (role: string) => void;
10
+ /** The labels the app gives its own roles. */
11
+ roleLabels?: RoleLabels;
12
+ /** The words of an empty first option, such as WordPress's Change role to…, no such option when absent. */
13
+ placeholder?: string;
14
+ }
15
+ /**
16
+ * Renders the role menu, the roles the reader may give with the fewest capabilities first, as WordPress lists them.
17
+ * @param props - The roles the reader may give, the role chosen, what takes a pick, the labels and the empty option.
18
+ * @returns The select.
19
+ */
20
+ export declare function RoleSelect({ grantable, value, onChange, roleLabels, placeholder }: RoleSelectProps): import("react").JSX.Element;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { __ } from '@wordpress/i18n';
4
+ import { SelectControl } from '@wordpress/ui';
5
+ import { DOMAIN } from '../../domain.js';
6
+ import { roleLabelOf } from './words.js';
7
+ /**
8
+ * Renders the role menu, the roles the reader may give with the fewest capabilities first, as WordPress lists them.
9
+ * @param props - The roles the reader may give, the role chosen, what takes a pick, the labels and the empty option.
10
+ * @returns The select.
11
+ */
12
+ export function RoleSelect({ grantable, value, onChange, roleLabels, placeholder }) {
13
+ const roles = [...grantable]
14
+ .reverse()
15
+ .map((role) => ({ value: role, label: roleLabelOf(role, roleLabels) }));
16
+ const items = placeholder === undefined ? roles : [{ value: null, label: placeholder }, ...roles];
17
+ return (_jsx(SelectControl, { label: __('Role', DOMAIN), items: items, value: items.find((item) => (item.value ?? '') === value) ?? null, placeholder: placeholder, onValueChange: (item) => onChange(item?.value ?? '') }));
18
+ }
@@ -0,0 +1,34 @@
1
+ import type { ListView } from '@gopherium/godmin';
2
+ import type { View } from '@wordpress/dataviews';
3
+ import type { ReactElement, ReactNode } from 'react';
4
+ import type { UsersColumn } from './fields.js';
5
+ import type { ErrorMessages, RoleLabels } from './words.js';
6
+ /** What an app hands the users page: the view it holds, its settings and its own words, all as data. */
7
+ export interface UsersPageProps {
8
+ /** The view godmin's useListView holds in the address. */
9
+ list: ListView<View>;
10
+ /** The page sizes the list offers, none while the settings load. */
11
+ pageSizes?: number[];
12
+ /** Whether the settings naming the page size could not be read, every account then showing on one page. */
13
+ sizesFailed?: boolean;
14
+ /** Whether the site shows avatars, true when absent. */
15
+ showAvatars?: boolean;
16
+ /** The labels the app gives its own roles. */
17
+ roleLabels?: RoleLabels;
18
+ /** The words the app gives the error codes only it answers. */
19
+ errorMessages?: ErrorMessages;
20
+ /** The link element the Add User words go in, no header button when absent. */
21
+ addLink?: ReactElement<Record<string, unknown>>;
22
+ /** The tabs drawn under the header. */
23
+ tabs?: ReactNode;
24
+ /** The level of the page title, 2 on a screen beside the sidebar. */
25
+ headingLevel?: 1 | 2;
26
+ /** The columns the app adds as data, after Role. */
27
+ columns?: readonly UsersColumn[];
28
+ }
29
+ /**
30
+ * Renders the Users page: the header, the accounts as a list and what a reader may do with them.
31
+ * @param props - The view the app holds, its settings and its own words.
32
+ * @returns The page.
33
+ */
34
+ export declare function UsersPage(props: UsersPageProps): import("react").JSX.Element;
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { ErrorNotice, ListEmpty, Page, focusedTrigger, openOnTap, useBulkRun, useFocusBack, useListRun, useUnsavedText, viewNarrows, } from '@gopherium/godmin';
4
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
5
+ import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews';
6
+ import { __, _x, sprintf } from '@wordpress/i18n';
7
+ import { people } from '@wordpress/icons';
8
+ import { ButtonLink } from '@wordpress/ui';
9
+ import { useCallback, useMemo, useState } from 'react';
10
+ import { fetchUsers, usersQueryKey } from '../../admin/index.js';
11
+ import { DOMAIN } from '../../domain.js';
12
+ import { useSession } from '../../session.js';
13
+ import { mayChangeRole, useUsersActions } from './actions.js';
14
+ import { OpenRoleDialog } from './ChangeRoleDialog.js';
15
+ import { usersFields } from './fields.js';
16
+ /** The accounts the list holds before the first read answers. */
17
+ const NO_ACCOUNTS = [];
18
+ /** The columns of a list the app adds none to. */
19
+ const NO_COLUMNS = [];
20
+ /** The labels of a list whose app names no role. */
21
+ const NO_LABELS = {};
22
+ /** The words of a list whose app answers no error code of its own. */
23
+ const NO_MESSAGES = {};
24
+ /** The roles a reader may give while the session names none. */
25
+ const NO_ROLES = [];
26
+ /**
27
+ * Returns the words of the header button adding an account.
28
+ * @returns The words in the reader's language.
29
+ */
30
+ function addUserLabel() {
31
+ return __('Add User', DOMAIN);
32
+ }
33
+ /**
34
+ * Renders what the list shows when no account is on it.
35
+ * @param props - Whether a search or a filter narrowed the list.
36
+ * @returns The empty state, telling how to add an account when nothing narrowed the list.
37
+ */
38
+ function UsersEmpty({ narrowed }) {
39
+ if (narrowed) {
40
+ return _jsx(ListEmpty, { icon: people, title: __('No users found.', DOMAIN) });
41
+ }
42
+ const hint = sprintf(__('Add one with %(button)s.', DOMAIN), { button: addUserLabel() });
43
+ return _jsx(ListEmpty, { icon: people, title: __('No users yet.', DOMAIN), hint: hint });
44
+ }
45
+ /**
46
+ * Renders the accounts as a list to search, filter, sort and page, with the actions the reader may take.
47
+ * @param props - The account query, the actions, the reader, what opens Change role and what the app hands the page.
48
+ * @returns The list, opening Change role from a name or a phone tap on an account the reader may change.
49
+ */
50
+ function UsersList({ users, actions, reader, openRole, ...props }) {
51
+ const { list } = props;
52
+ const accounts = users.data ?? NO_ACCOUNTS;
53
+ const showAvatars = props.showAvatars ?? true;
54
+ const roleLabels = props.roleLabels ?? NO_LABELS;
55
+ const columns = props.columns ?? NO_COLUMNS;
56
+ const fields = useMemo(() => usersFields(accounts, { roleLabels, showAvatars, columns, phone: list.phone }), [accounts, roleLabels, showAvatars, columns, list.phone]);
57
+ const shown = filterSortAndPaginate(accounts, list.view, fields);
58
+ const sizing = list.view.perPage === undefined && props.sizesFailed !== true;
59
+ const changeable = (account) => mayChangeRole(reader, account);
60
+ const tapped = (id) => {
61
+ const account = accounts.find((held) => held.id === id);
62
+ if (changeable(account)) {
63
+ openRole([account], 'row');
64
+ }
65
+ };
66
+ return (_jsx(DataViews, { data: shown.data, paginationInfo: shown.paginationInfo, fields: fields, view: list.view, onChangeView: list.onChangeView, defaultLayouts: list.defaultLayouts, selection: list.selection, onChangeSelection: openOnTap(list, tapped), isItemClickable: changeable, onClickItem: (account) => openRole([account], focusedTrigger()), actions: actions, isLoading: users.isPending || sizing, searchLabel: __('Search users…', DOMAIN), config: props.pageSizes === undefined ? undefined : { perPageSizes: props.pageSizes }, empty: _jsx(UsersEmpty, { narrowed: viewNarrows(list.view) }) }));
67
+ }
68
+ /**
69
+ * Renders the Users page: the header, the accounts as a list and what a reader may do with them.
70
+ * @param props - The view the app holds, its settings and its own words.
71
+ * @returns The page.
72
+ */
73
+ export function UsersPage(props) {
74
+ const users = useQuery({ queryKey: usersQueryKey, queryFn: ({ signal }) => fetchUsers(signal) });
75
+ const reader = useSession().data ?? null;
76
+ const client = useQueryClient();
77
+ const { run, region } = useListRun('users', props.list.onChangeSelection);
78
+ const reload = useCallback(() => client.invalidateQueries({ queryKey: usersQueryKey }), [client]);
79
+ const switches = useBulkRun(run, reload);
80
+ const unsaved = useUnsavedText();
81
+ const links = useUnsavedText();
82
+ const focusBack = useFocusBack(users.isFetching, region);
83
+ const [opened, setOpened] = useState();
84
+ const openRole = useCallback((accounts, trigger) => setOpened({ accounts, trigger }), []);
85
+ const errorMessages = props.errorMessages ?? NO_MESSAGES;
86
+ const roleLabels = props.roleLabels ?? NO_LABELS;
87
+ const actions = useUsersActions({ reader, switches, links, errorMessages, openRole });
88
+ const grantable = reader?.grantable ?? NO_ROLES;
89
+ const title = _x('Users', 'admin section', DOMAIN);
90
+ return (_jsxs(Page, { title: title, headingLevel: props.headingLevel, subtitle: __('Manage who can sign in and what they can do.', DOMAIN), actions: props.addLink === undefined ? undefined : (_jsx(ButtonLink, { variant: "solid", size: "compact", render: props.addLink, children: addUserLabel() })), tabs: props.tabs, list: true, listLabel: title, listRef: region, children: [users.isError ? (_jsx(ErrorNotice, { children: __('Users could not be loaded.', DOMAIN) })) : (_jsx(UsersList, { users: users, actions: actions, reader: reader, openRole: openRole, ...props })), _jsx(OpenRoleDialog, { opened: opened, tools: { grantable, roleLabels, errorMessages, unsaved, reload }, onClose: () => setOpened(undefined), onClearSelection: () => props.list.onChangeSelection([]), onBack: focusBack })] }));
91
+ }
@@ -0,0 +1,31 @@
1
+ import type { BulkRun, Trigger, UnsavedText } from '@gopherium/godmin';
2
+ import type { Action } from '@wordpress/dataviews';
3
+ import type { Account } from '../../account.js';
4
+ import type { User } from '../../api.js';
5
+ import type { ErrorMessages } from './words.js';
6
+ /** What the actions of the users list need from the page. */
7
+ export interface ActionTools {
8
+ /** The signed-in account, none while nobody is signed in. */
9
+ reader: User | null;
10
+ /** Runs an Enable or a Disable over the picked accounts, reads the list again and shows one toast. */
11
+ switches: BulkRun<Account>;
12
+ /** The activation links resends answered, by account id, kept until a dialog showing one closes. */
13
+ links: UnsavedText;
14
+ /** The words the app gives the error codes only it answers. */
15
+ errorMessages: ErrorMessages;
16
+ /** Opens Change role on the accounts, given the trigger it opened from. */
17
+ openRole: (accounts: Account[], trigger: Trigger) => void;
18
+ }
19
+ /**
20
+ * Reports whether the reader may change the role of an account, one it may edit while it may give some role.
21
+ * @param reader - The signed-in account, none while nobody is signed in.
22
+ * @param account - The account a row shows.
23
+ * @returns Whether Change role applies.
24
+ */
25
+ export declare function mayChangeRole(reader: User | null, account: Account): boolean;
26
+ /**
27
+ * Returns the row and bulk actions of the users list, none on the reader's own row nor on one it may not edit.
28
+ * @param tools - The reader, the run, the app's error words and what opens Change role.
29
+ * @returns Change role, Resend invitation, Enable and Disable, in the order the bulk bar draws them, none signed out.
30
+ */
31
+ export declare function useUsersActions(tools: ActionTools): Action<Account>[];
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { focusedTrigger } from '@gopherium/godmin';
4
+ import { __ } from '@wordpress/i18n';
5
+ import { check, notAllowed, pencil } from '@wordpress/icons';
6
+ import { useMemo } from 'react';
7
+ import { setUserDisabled } from '../../admin/index.js';
8
+ import { DOMAIN } from '../../domain.js';
9
+ import { ResendModal } from './ResendModal.js';
10
+ import { switchWords } from './words.js';
11
+ /**
12
+ * Reports whether the reader may act on an account: never its own, and only one the server says it may edit.
13
+ * @param reader - The signed-in account.
14
+ * @param account - The account a row shows.
15
+ * @returns Whether a row action may apply.
16
+ */
17
+ function mayEdit(reader, account) {
18
+ return account.id !== reader.id && account.editable;
19
+ }
20
+ /**
21
+ * Reports whether the reader may change the role of an account, one it may edit while it may give some role.
22
+ * @param reader - The signed-in account, none while nobody is signed in.
23
+ * @param account - The account a row shows.
24
+ * @returns Whether Change role applies.
25
+ */
26
+ export function mayChangeRole(reader, account) {
27
+ return reader !== null && mayEdit(reader, account) && (reader.grantable ?? []).length > 0;
28
+ }
29
+ /**
30
+ * Returns the row and bulk actions of the users list, none on the reader's own row nor on one it may not edit.
31
+ * @param tools - The reader, the run, the app's error words and what opens Change role.
32
+ * @returns Change role, Resend invitation, Enable and Disable, in the order the bulk bar draws them, none signed out.
33
+ */
34
+ export function useUsersActions(tools) {
35
+ const { reader, switches, links, errorMessages, openRole } = tools;
36
+ return useMemo(() => {
37
+ if (reader === null) {
38
+ return [];
39
+ }
40
+ const grantable = reader.grantable ?? [];
41
+ const switchAccounts = (accounts, disabled) => switches(accounts, (account) => setUserDisabled(account.id, disabled), switchWords(disabled, errorMessages));
42
+ const actions = [
43
+ {
44
+ id: 'change-role',
45
+ label: __('Change role…', DOMAIN),
46
+ icon: pencil,
47
+ supportsBulk: true,
48
+ isEligible: (account) => mayChangeRole(reader, account),
49
+ callback: (accounts) => openRole(accounts, focusedTrigger()),
50
+ },
51
+ {
52
+ id: 'resend-invitation',
53
+ label: __('Resend invitation…', DOMAIN),
54
+ modalHeader: __('Resend invitation', DOMAIN),
55
+ modalFocusOnMount: 'firstContentElement',
56
+ isEligible: (account) => mayEdit(reader, account) && !account.confirmed && !account.disabled,
57
+ RenderModal: (props) => _jsx(ResendModal, { ...props, links: links, errorMessages: errorMessages }),
58
+ },
59
+ {
60
+ id: 'enable',
61
+ label: __('Enable', DOMAIN),
62
+ icon: check,
63
+ isPrimary: true,
64
+ supportsBulk: true,
65
+ isEligible: (account) => mayEdit(reader, account) && account.disabled && grantable.includes(account.role),
66
+ callback: (accounts) => switchAccounts(accounts, false),
67
+ },
68
+ {
69
+ id: 'disable',
70
+ label: __('Disable', DOMAIN),
71
+ icon: notAllowed,
72
+ supportsBulk: true,
73
+ isEligible: (account) => mayEdit(reader, account) && !account.disabled,
74
+ callback: (accounts) => switchAccounts(accounts, true),
75
+ },
76
+ ];
77
+ return actions;
78
+ }, [reader, switches, links, errorMessages, openRole]);
79
+ }
@@ -0,0 +1,14 @@
1
+ import type { ListDefaults } from '@gopherium/godmin';
2
+ /** What an app names for the view the users list opens on. */
3
+ export interface UsersListOptions {
4
+ /** How many accounts a page holds, every account while it is unknown. */
5
+ perPage?: number;
6
+ /** The ids of the data columns the app passes, shown after Role in the table and never in the phone row. */
7
+ columns?: readonly string[];
8
+ }
9
+ /**
10
+ * Returns the view the users list opens on, as data for godmin's useListView.
11
+ * @param options - The page size and the data columns the app passes.
12
+ * @returns The fields, the title, the line under it, the avatar, the order and the page size.
13
+ */
14
+ export declare function usersListDefaults({ perPage, columns }?: UsersListOptions): ListDefaults;
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Returns the view the users list opens on, as data for godmin's useListView.
4
+ * @param options - The page size and the data columns the app passes.
5
+ * @returns The fields, the title, the line under it, the avatar, the order and the page size.
6
+ */
7
+ export function usersListDefaults({ perPage, columns = [] } = {}) {
8
+ return {
9
+ fields: ['status', 'role', ...columns, 'created'],
10
+ phoneFields: ['status', 'role', 'created'],
11
+ titleField: 'name',
12
+ descriptionField: 'email',
13
+ mediaField: 'avatar',
14
+ sort: { field: 'created', direction: 'desc' },
15
+ perPage,
16
+ };
17
+ }
@@ -0,0 +1,40 @@
1
+ import type { Field } from '@wordpress/dataviews';
2
+ import type { ReactElement } from 'react';
3
+ import type { Account } from '../../account.js';
4
+ import type { RoleLabels } from './words.js';
5
+ /** One cell of a data column: the words shown, the words a screen reader hears and the link the words go in. */
6
+ export interface UsersColumnCell {
7
+ /** The words shown in the cell. */
8
+ text: string;
9
+ /** The words a screen reader hears in place of the shown ones, the shown ones when absent. */
10
+ hidden?: string;
11
+ /** The link element the words go in on a wide screen, none for plain words. */
12
+ link?: ReactElement;
13
+ }
14
+ /** A column an app adds to the users list as data, shown after Role. */
15
+ export interface UsersColumn {
16
+ /** The id the view names the column by. */
17
+ id: string;
18
+ /** The column heading. */
19
+ label: string;
20
+ /** The cell of each account, keyed by account id, an account missing from it showing an empty cell. */
21
+ cells: Readonly<Record<string, UsersColumnCell>>;
22
+ }
23
+ /** What the fields of the users list are built from beside the accounts. */
24
+ export interface FieldOptions {
25
+ /** The labels the app gives its own roles. */
26
+ roleLabels: RoleLabels;
27
+ /** Whether the site shows avatars, the list declaring no avatar field when it does not. */
28
+ showAvatars: boolean;
29
+ /** The data columns the app adds. */
30
+ columns: readonly UsersColumn[];
31
+ /** Whether the viewport takes the phone layout, where a column draws no link. */
32
+ phone: boolean;
33
+ }
34
+ /**
35
+ * Returns the fields the users list shows.
36
+ * @param accounts - Every account the list holds.
37
+ * @param options - The role labels, whether avatars show, the data columns and whether the viewport is a phone.
38
+ * @returns The fields.
39
+ */
40
+ export declare function usersFields(accounts: readonly Account[], options: FieldOptions): Field<Account>[];
@@ -0,0 +1,172 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { InitialsAvatar } from '@gopherium/godmin';
4
+ import { listDate } from '@gopherium/godmin/dates';
5
+ import { __, _x } from '@wordpress/i18n';
6
+ import { Badge, VisuallyHidden } from '@wordpress/ui';
7
+ import { cloneElement } from 'react';
8
+ import { DOMAIN } from '../../domain.js';
9
+ import { roleCellOf, roleLabelOf } from './words.js';
10
+ /** Every state an account stands in, in the order the filter offers them. */
11
+ const STATUSES = ['active', 'invited', 'disabled'];
12
+ /** The badge intent each state is drawn with, disabled in the outline one. */
13
+ const STATUS_INTENTS = {
14
+ active: 'stable',
15
+ invited: 'informational',
16
+ disabled: 'none',
17
+ };
18
+ /** The width in pixels of the media box DataViews draws before each name in the phone list. */
19
+ const PHONE_AVATAR_SIZE = 48;
20
+ /**
21
+ * Returns the state one account stands in.
22
+ * @param account - The account.
23
+ * @returns Disabled before invited, invited before active.
24
+ */
25
+ function statusOf(account) {
26
+ if (account.disabled) {
27
+ return 'disabled';
28
+ }
29
+ return account.confirmed ? 'active' : 'invited';
30
+ }
31
+ /**
32
+ * Returns the label each state reads as, read fresh so the loaded catalogue answers.
33
+ * @returns The labels, keyed by state.
34
+ */
35
+ function statusLabels() {
36
+ return {
37
+ active: _x('Active', 'user status', DOMAIN),
38
+ invited: _x('Invited', 'user status', DOMAIN),
39
+ disabled: _x('Disabled', 'user status', DOMAIN),
40
+ };
41
+ }
42
+ /**
43
+ * Returns the name field, the title marked with its account so a dialog finds the row again.
44
+ * @returns The field.
45
+ */
46
+ function nameField() {
47
+ return {
48
+ id: 'name',
49
+ label: __('Name', DOMAIN),
50
+ type: 'text',
51
+ enableGlobalSearch: true,
52
+ enableHiding: false,
53
+ filterBy: false,
54
+ render: ({ item }) => _jsx("span", { "data-item-id": item.id, children: item.name }),
55
+ };
56
+ }
57
+ /**
58
+ * Returns the email field, the line under the name.
59
+ * @returns The field.
60
+ */
61
+ function emailField() {
62
+ return { id: 'email', label: __('Email', DOMAIN), type: 'text', enableGlobalSearch: true, filterBy: false };
63
+ }
64
+ /**
65
+ * Returns the avatar field, the picture the server sends or the initial of the name.
66
+ * @param phone - Whether the viewport takes the phone layout, whose media box is larger.
67
+ * @returns The field.
68
+ */
69
+ function avatarField(phone) {
70
+ const size = phone ? PHONE_AVATAR_SIZE : undefined;
71
+ return {
72
+ id: 'avatar',
73
+ label: __('Avatar', DOMAIN),
74
+ render: ({ item }) => _jsx(InitialsAvatar, { name: item.name, src: item.avatar, size: size }),
75
+ enableSorting: false,
76
+ filterBy: false,
77
+ };
78
+ }
79
+ /**
80
+ * Returns the status field, offering as filter values only the states the accounts stand in.
81
+ * @param accounts - Every account the list holds.
82
+ * @returns The field.
83
+ */
84
+ function statusField(accounts) {
85
+ const labels = statusLabels();
86
+ const held = new Set(accounts.map(statusOf));
87
+ return {
88
+ id: 'status',
89
+ label: __('Status', DOMAIN),
90
+ type: 'text',
91
+ getValue: ({ item }) => statusOf(item),
92
+ elements: STATUSES.filter((status) => held.has(status)).map((status) => ({ value: status, label: labels[status] })),
93
+ filterBy: { operators: ['isAny'] },
94
+ enableSorting: false,
95
+ render: ({ item }) => _jsx(Badge, { intent: STATUS_INTENTS[statusOf(item)], children: labels[statusOf(item)] }),
96
+ };
97
+ }
98
+ /**
99
+ * Returns the role field, offering as filter values only the roles the accounts hold, by label from A to Z.
100
+ * @param accounts - Every account the list holds.
101
+ * @param roleLabels - The labels the app gives its own roles.
102
+ * @returns The field.
103
+ */
104
+ function roleField(accounts, roleLabels) {
105
+ const roles = [...new Set(accounts.map((account) => account.role))]
106
+ .map((role) => ({ value: role, label: roleLabelOf(role, roleLabels) }))
107
+ .sort((a, b) => a.label.localeCompare(b.label));
108
+ return {
109
+ id: 'role',
110
+ label: __('Role', DOMAIN),
111
+ type: 'text',
112
+ elements: roles,
113
+ filterBy: { operators: ['isAny'] },
114
+ enableSorting: false,
115
+ render: ({ item }) => roleCellOf(item.role, roleLabels),
116
+ };
117
+ }
118
+ /**
119
+ * Returns the words of one data column cell, a screen reader hearing its own words when the app gives them.
120
+ * @param cell - The cell the app gives the account.
121
+ * @param phone - Whether the viewport takes the phone layout, where the words go in no link.
122
+ * @returns The words, in the link on a wide screen.
123
+ */
124
+ function cellWords(cell, phone) {
125
+ const words = cell.hidden === undefined ? (cell.text) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: cell.text }), _jsx(VisuallyHidden, { children: cell.hidden })] }));
126
+ return phone || cell.link === undefined ? words : cloneElement(cell.link, undefined, words);
127
+ }
128
+ /**
129
+ * Returns the field one data column becomes, never sorted nor filtered.
130
+ * @param column - The column the app adds.
131
+ * @param phone - Whether the viewport takes the phone layout.
132
+ * @returns The field.
133
+ */
134
+ function columnField(column, phone) {
135
+ return {
136
+ id: column.id,
137
+ label: column.label,
138
+ enableSorting: false,
139
+ filterBy: false,
140
+ render: ({ item }) => (Object.hasOwn(column.cells, item.id) ? cellWords(column.cells[item.id], phone) : null),
141
+ };
142
+ }
143
+ /**
144
+ * Returns the created field, the abbreviated date and time WordPress lists show, sorted by the moment.
145
+ * @returns The field.
146
+ */
147
+ function createdField() {
148
+ return {
149
+ id: 'created',
150
+ label: __('Created', DOMAIN),
151
+ getValue: ({ item }) => item.created_at.getTime(),
152
+ render: ({ item }) => listDate(item.created_at),
153
+ filterBy: false,
154
+ };
155
+ }
156
+ /**
157
+ * Returns the fields the users list shows.
158
+ * @param accounts - Every account the list holds.
159
+ * @param options - The role labels, whether avatars show, the data columns and whether the viewport is a phone.
160
+ * @returns The fields.
161
+ */
162
+ export function usersFields(accounts, options) {
163
+ return [
164
+ nameField(),
165
+ emailField(),
166
+ ...(options.showAvatars ? [avatarField(options.phone)] : []),
167
+ statusField(accounts),
168
+ roleField(accounts, options.roleLabels),
169
+ ...options.columns.map((column) => columnField(column, options.phone)),
170
+ createdField(),
171
+ ];
172
+ }
@@ -0,0 +1,9 @@
1
+ export { usersListDefaults } from './defaults.js';
2
+ export type { UsersListOptions } from './defaults.js';
3
+ export type { UsersColumn, UsersColumnCell } from './fields.js';
4
+ export { RoleSelect } from './RoleSelect.js';
5
+ export type { RoleSelectProps } from './RoleSelect.js';
6
+ export { UsersPage } from './UsersPage.js';
7
+ export type { UsersPageProps } from './UsersPage.js';
8
+ export { errorMessageOf, roleLabelOf } from './words.js';
9
+ export type { ErrorMessages, RoleLabels } from './words.js';
@@ -0,0 +1,5 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ export { usersListDefaults } from './defaults.js';
3
+ export { RoleSelect } from './RoleSelect.js';
4
+ export { UsersPage } from './UsersPage.js';
5
+ export { errorMessageOf, roleLabelOf } from './words.js';
@@ -0,0 +1,47 @@
1
+ import type { BulkWords } from '@gopherium/godmin';
2
+ import type { Account } from '../../account.js';
3
+ /** The label an app gives each role it names, keyed by the role the server stores. */
4
+ export type RoleLabels = Readonly<Record<string, string>>;
5
+ /** The words an app gives each error code only it answers, keyed by the code. */
6
+ export type ErrorMessages = Readonly<Record<string, string>>;
7
+ /**
8
+ * Returns the label a role reads as, a role the app names no label for reading as its stored name with a capital.
9
+ * @param role - The role the server stores, empty for an account holding none.
10
+ * @param roleLabels - The labels the app gives its own roles.
11
+ * @returns The label, No role for an empty role the app names no label for.
12
+ */
13
+ export declare function roleLabelOf(role: string, roleLabels?: RoleLabels): string;
14
+ /**
15
+ * Returns the words a Role cell reads for an account, None for one holding no role as WordPress writes it.
16
+ * @param role - The role the server stores, empty for an account holding none.
17
+ * @param roleLabels - The labels the app gives its own roles.
18
+ * @returns The words.
19
+ */
20
+ export declare function roleCellOf(role: string, roleLabels: RoleLabels): string;
21
+ /**
22
+ * Returns the words a failed account write reads as: the words for its code, else the app's, else the fallback.
23
+ * @param error - What the write failed with.
24
+ * @param errorMessages - The words the app gives the codes only it answers.
25
+ * @param fallback - The words for an error with no code either side words.
26
+ * @returns The words.
27
+ */
28
+ export declare function errorMessageOf(error: unknown, errorMessages: ErrorMessages, fallback: string): string;
29
+ /**
30
+ * Returns the line Change role opens with, naming the one account or counting several.
31
+ * @param accounts - The accounts the dialog acts on.
32
+ * @returns The line.
33
+ */
34
+ export declare function roleLineOf(accounts: readonly Account[]): string;
35
+ /**
36
+ * Returns the words a Change role run reports its outcome in, WordPress's Changed roles. for several.
37
+ * @param errorMessages - The words the app gives the codes only it answers.
38
+ * @returns The words for the accounts changed, naming one account whole, and for the ones that failed.
39
+ */
40
+ export declare function roleWords(errorMessages: ErrorMessages): BulkWords<Account>;
41
+ /**
42
+ * Returns the words an Enable or a Disable run reports its outcome in.
43
+ * @param disabled - Whether the run disables the accounts rather than enables them.
44
+ * @param errorMessages - The words the app gives the codes only it answers.
45
+ * @returns The words for the accounts changed and for the ones that failed.
46
+ */
47
+ export declare function switchWords(disabled: boolean, errorMessages: ErrorMessages): BulkWords<Account>;