Allow org admins to add members as co-authors (#20008)
* Basics might be working? * Stop propagating button clicks in autocomplete pills * Better blank slate * Better location to stop propagation * Remove author_id from org co-authors * Move UserStore and try testing it * Remove extraneous comments * OH... that's what that does! * Very basic testing * Re-organize javascripts * Rename & re-org for testing * Cleanup * More tests * Remove unnecessary nesting * Coninuing to try to bump coverage * Include /packs/ in code coverage metric * Try tweaking jest coverage more? We probably can't collect coverage from all of packs/* (because coverage is too low) but maybe we can try to opt-in for newer areas as we go? * Relocate JS tests, for build & coverage * User ID exception on search, not fetch * Remove commented-out console.log --------- Co-authored-by: Mac Siri <krairit.siri@gmail.com>
This commit is contained in:
parent
612c402887
commit
6fe9d7d7a6
20 changed files with 552 additions and 4 deletions
|
|
@ -328,8 +328,9 @@ class ArticlesController < ApplicationController
|
|||
|
||||
# NOTE: the organization logic is still a little counter intuitive but this should
|
||||
# fix the bug <https://github.com/forem/forem/issues/2871>
|
||||
if params["article"]["user_id"] && org_admin_user_change_privilege
|
||||
if org_admin_user_change_privilege
|
||||
allowed_params << :user_id
|
||||
allowed_params << :co_author_ids_list
|
||||
elsif params["article"]["organization_id"] && allowed_to_change_org_id?
|
||||
# change the organization of the article only if explicitly asked to do so
|
||||
allowed_params << :organization_id
|
||||
|
|
|
|||
|
|
@ -97,6 +97,11 @@ class OrganizationsController < ApplicationController
|
|||
def members
|
||||
@organization = Organization.find_by(slug: params[:slug])
|
||||
@members = @organization.users
|
||||
|
||||
respond_to do |format|
|
||||
format.json { render json: @members.to_json(only: %i[id name username]) }
|
||||
format.html
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`convertCoauthorIdsToUsernameInputs when there *is* a pre-existing id value renders matching snapshot 1`] = `
|
||||
"
|
||||
<div class=\\"crayons-field mb-4\\">
|
||||
<label for=\\"user_id\\">Author</label>
|
||||
<select class=\\"crayons-select\\" name=\\"article[user_id]\\" id=\\"article_user_id\\">
|
||||
<option selected=\\"selected\\" value=\\"1\\">
|
||||
Alice
|
||||
</option>
|
||||
<option value=\\"2\\">
|
||||
Bob
|
||||
</option>
|
||||
<option value=\\"3\\">
|
||||
Charlie
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class=\\"crayons-field mb-4\\">
|
||||
<label for=\\"co_author_ids\\">Co-authors</label>
|
||||
<div class=\\"crayons-field\\"><span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"screen-reader-only\\">Add up to 4</label><span id=\\"input-description\\" class=\\"screen-reader-only\\">Maximum 4 selections</span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\" class=\\"screen-reader-only list-none\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li style=\\"order: 1;\\" class=\\"c-autocomplete--multi__selection-list-item w-max\\"><div role=\\"group\\" aria-label=\\"Charlie\\" class=\\"flex mr-1 mb-1 w-max\\"><button type=\\"button\\" aria-label=\\"Edit Charlie\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1 cursor-text\\">Charlie</button><button type=\\"button\\" aria-label=\\"Remove Charlie\\" class=\\"c-btn c-btn--secondary c-autocomplete--multi__selected p-1\\"><svg width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" class=\\"crayons-icon\\"><path d=\\"m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z\\"></path></svg></button></div></li><li style=\\"order: 2;\\" class=\\"self-center\\"><input id=\\"autoarticle_ID_co_author_ids_list\\" autocomplete=\\"off\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add another...\\" class=\\"c-autocomplete--multi__input\\"></li></ul></div></div>
|
||||
<input id=\\"article_ID_co_author_ids_list\\" class=\\"article_org_co_author_ids_list\\" name=\\"article[co_author_ids_list]\\" data-fetch-users=\\"/org7053/members.json\\" type=\\"hidden\\" value=\\"3\\">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type=\\"submit\\" class=\\"crayons-btn\\">Save</button>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`convertCoauthorIdsToUsernameInputs when there is no pre-existing id value renders matching snapshot 1`] = `
|
||||
"
|
||||
<div class=\\"crayons-field mb-4\\">
|
||||
<label for=\\"user_id\\">Author</label>
|
||||
<select class=\\"crayons-select\\" name=\\"article[user_id]\\" id=\\"article_user_id\\">
|
||||
<option selected=\\"selected\\" value=\\"1\\">
|
||||
Alice
|
||||
</option>
|
||||
<option value=\\"2\\">
|
||||
Bob
|
||||
</option>
|
||||
<option value=\\"3\\">
|
||||
Charlie
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class=\\"crayons-field mb-4\\">
|
||||
<label for=\\"co_author_ids\\">Co-authors</label>
|
||||
<div class=\\"crayons-field\\"><span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"screen-reader-only\\">Add up to 4</label><span id=\\"input-description\\" class=\\"screen-reader-only\\">Maximum 4 selections</span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\" class=\\"screen-reader-only list-none\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li style=\\"order: 1;\\" class=\\"self-center\\"><input id=\\"autoarticle_ID_co_author_ids_list\\" autocomplete=\\"off\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add up to 4...\\" class=\\"c-autocomplete--multi__input\\"></li></ul></div></div>
|
||||
<input id=\\"article_ID_co_author_ids_list\\" class=\\"article_org_co_author_ids_list\\" name=\\"article[co_author_ids_list]\\" data-fetch-users=\\"/org7053/members.json\\" type=\\"hidden\\" value=\\"\\">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type=\\"submit\\" class=\\"crayons-btn\\">Save</button>
|
||||
"
|
||||
`;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getBillboard } from '../billboard';
|
||||
import { getBillboard } from '../packs/billboard';
|
||||
|
||||
describe('getBillboard', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import fetch from 'jest-fetch-mock';
|
||||
import '@testing-library/jest-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { convertCoauthorIdsToUsernameInputs } from '../packs/dashboards/convertCoauthorIdsToUsernameInputs';
|
||||
|
||||
global.fetch = fetch;
|
||||
|
||||
function fakeFetchResponseJSON() {
|
||||
return JSON.stringify([
|
||||
{ name: 'Alice', username: 'alice', id: 1 },
|
||||
{ name: 'Bob', username: 'bob', id: 2 },
|
||||
{ name: 'Charlie', username: 'charlie', id: 3 },
|
||||
]);
|
||||
}
|
||||
|
||||
describe('convertCoauthorIdsToUsernameInputs', () => {
|
||||
beforeEach(() => {
|
||||
global.Honeybadger = { notify: jest.fn() };
|
||||
|
||||
fetch.resetMocks();
|
||||
fetch.mockResponse(fakeFetchResponseJSON());
|
||||
});
|
||||
|
||||
describe('when there is no pre-existing id value', () => {
|
||||
beforeEach(() => {
|
||||
window.document.body.innerHTML = `
|
||||
<form method="post">
|
||||
<div class="crayons-field mb-4">
|
||||
<label for="user_id">Author</label>
|
||||
<select class="crayons-select" name="article[user_id]" id="article_user_id">
|
||||
<option selected="selected" value="1">
|
||||
Alice
|
||||
</option>
|
||||
<option value="2">
|
||||
Bob
|
||||
</option>
|
||||
<option value="3">
|
||||
Charlie
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="crayons-field mb-4">
|
||||
<label for="co_author_ids">Co-authors</label>
|
||||
<div class="crayons-field">
|
||||
<input id="article_ID_co_author_ids_list"
|
||||
class="article_org_co_author_ids_list"
|
||||
name="article[co_author_ids_list]"
|
||||
data-fetch-users="/org7053/members.json"
|
||||
type="text"
|
||||
value="" >
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="crayons-btn">Save</button>
|
||||
</form>
|
||||
`;
|
||||
});
|
||||
|
||||
it('calls fetch, with exception for author ID', async () => {
|
||||
await convertCoauthorIdsToUsernameInputs();
|
||||
expect(fetch).toHaveBeenCalledWith('/org7053/members.json');
|
||||
});
|
||||
|
||||
it('makes the co-author field hidden', async () => {
|
||||
await convertCoauthorIdsToUsernameInputs();
|
||||
const co_author_field = document.getElementById(
|
||||
'article_ID_co_author_ids_list',
|
||||
);
|
||||
expect(co_author_field.type).toBe('hidden');
|
||||
});
|
||||
|
||||
it('renders matching snapshot', async () => {
|
||||
await convertCoauthorIdsToUsernameInputs();
|
||||
expect(document.forms[0].innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('works as expected', async () => {
|
||||
await convertCoauthorIdsToUsernameInputs();
|
||||
const input = document.querySelector(
|
||||
"input[placeholder='Add up to 4...']",
|
||||
);
|
||||
input.focus();
|
||||
await userEvent.type(input, 'Bob,');
|
||||
|
||||
const hiddenField = document.querySelector(
|
||||
"input[name='article[co_author_ids_list]']",
|
||||
);
|
||||
expect(hiddenField.value).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there *is* a pre-existing id value', () => {
|
||||
beforeEach(() => {
|
||||
window.document.body.innerHTML = `
|
||||
<form method="post">
|
||||
<div class="crayons-field mb-4">
|
||||
<label for="user_id">Author</label>
|
||||
<select class="crayons-select" name="article[user_id]" id="article_user_id">
|
||||
<option selected="selected" value="1">
|
||||
Alice
|
||||
</option>
|
||||
<option value="2">
|
||||
Bob
|
||||
</option>
|
||||
<option value="3">
|
||||
Charlie
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="crayons-field mb-4">
|
||||
<label for="co_author_ids">Co-authors</label>
|
||||
<div class="crayons-field">
|
||||
<input id="article_ID_co_author_ids_list"
|
||||
class="article_org_co_author_ids_list"
|
||||
name="article[co_author_ids_list]"
|
||||
data-fetch-users="/org7053/members.json"
|
||||
type="text"
|
||||
value="3" >
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="crayons-btn">Save</button>
|
||||
</form>
|
||||
`;
|
||||
});
|
||||
|
||||
it('renders matching snapshot', async () => {
|
||||
await convertCoauthorIdsToUsernameInputs();
|
||||
expect(document.forms[0].innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('works as expected', async () => {
|
||||
await convertCoauthorIdsToUsernameInputs();
|
||||
const input = document.querySelector(
|
||||
"input[placeholder='Add another...']",
|
||||
);
|
||||
input.focus();
|
||||
await userEvent.type(input, 'Bob,');
|
||||
|
||||
const hiddenField = document.querySelector(
|
||||
"input[name='article[co_author_ids_list]']",
|
||||
);
|
||||
expect(hiddenField.value).toBe('3, 2');
|
||||
});
|
||||
|
||||
it('can remove previously selected', async () => {
|
||||
await convertCoauthorIdsToUsernameInputs();
|
||||
const deselect = document.querySelector(
|
||||
'.c-autocomplete--multi__selected',
|
||||
);
|
||||
await userEvent.click(deselect);
|
||||
|
||||
const hiddenField = document.querySelector(
|
||||
"input[name='article[co_author_ids_list]']",
|
||||
);
|
||||
expect(hiddenField.value).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -598,7 +598,12 @@ export const MultiSelectAutocomplete = ({
|
|||
className={`c-autocomplete--multi__wrapper${
|
||||
border ? '-border crayons-textfield' : ' border-none p-0'
|
||||
} flex items-center cursor-text`}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
onClick={(event) => {
|
||||
// Stopping propagation here so that clicks on the 'x' close button
|
||||
// don't appear to be "outside" of any container (eg, dropdown)
|
||||
event.stopPropagation();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<ul id="combo-selected" className="list-none flex flex-wrap w-100">
|
||||
{allSelectedItemElements}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { h, render } from 'preact';
|
||||
import { UsernameInput } from '../../shared/components/UsernameInput';
|
||||
import { UserStore } from '../../shared/components/UserStore';
|
||||
import '@utilities/document_ready';
|
||||
|
||||
export async function convertCoauthorIdsToUsernameInputs() {
|
||||
const usernameFields = document.getElementsByClassName(
|
||||
'article_org_co_author_ids_list',
|
||||
);
|
||||
|
||||
const users = new UserStore();
|
||||
|
||||
for (const targetField of usernameFields) {
|
||||
targetField.type = 'hidden';
|
||||
const exceptAuthorId =
|
||||
targetField.form.querySelector('#article_user_id').value;
|
||||
const inputId = `auto${targetField.id}`;
|
||||
const fetchUrl = targetField.dataset.fetchUsers;
|
||||
const row = targetField.parentElement;
|
||||
|
||||
await users.fetch(fetchUrl).then(() => {
|
||||
const value = users.matchingIds(targetField.value.split(','));
|
||||
const fetchSuggestions = function (term) {
|
||||
return users.search(term, { except: exceptAuthorId });
|
||||
};
|
||||
|
||||
const handleSelectionsChanged = function (ids) {
|
||||
targetField.value = ids;
|
||||
};
|
||||
|
||||
render(
|
||||
<UsernameInput
|
||||
labelText="Add up to 4"
|
||||
placeholder="Add up to 4..."
|
||||
maxSelections={4}
|
||||
inputId={inputId}
|
||||
defaultValue={value}
|
||||
fetchSuggestions={fetchSuggestions}
|
||||
handleSelectionsChanged={handleSelectionsChanged}
|
||||
/>,
|
||||
row,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.ready.then(() => {
|
||||
convertCoauthorIdsToUsernameInputs();
|
||||
});
|
||||
59
app/javascript/shared/components/UserStore.js
Normal file
59
app/javascript/shared/components/UserStore.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export class UserStore {
|
||||
constructor() {
|
||||
this.users = [];
|
||||
this.wasFetched = false;
|
||||
}
|
||||
|
||||
fetch(url) {
|
||||
const myStore = this;
|
||||
return new Promise((resolve, _reject) => {
|
||||
if (myStore.wasFetched) {
|
||||
resolve();
|
||||
} else {
|
||||
window
|
||||
.fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
myStore.users = data.reduce((array, aUser) => {
|
||||
array.push(aUser);
|
||||
return array;
|
||||
}, []);
|
||||
myStore.wasFetched = true;
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
Honeybadger.notify(error);
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
matchingIds(arrayOfIds) {
|
||||
const allUsers = this.users;
|
||||
const someUsers = arrayOfIds.reduce((array, idString) => {
|
||||
const aUser = allUsers.find((user) => user.id == idString);
|
||||
if (typeof aUser != 'undefined') {
|
||||
array.push(aUser);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
return someUsers;
|
||||
}
|
||||
|
||||
search(term, options) {
|
||||
options ||= {};
|
||||
const { except } = options;
|
||||
const allUsers = this.users;
|
||||
const results = [];
|
||||
for (const aUser of allUsers) {
|
||||
if (
|
||||
aUser.id != except &&
|
||||
(aUser.name.search(term) >= 0 || aUser.username.search(term) >= 0)
|
||||
) {
|
||||
results.push(aUser);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
49
app/javascript/shared/components/UsernameInput.jsx
Normal file
49
app/javascript/shared/components/UsernameInput.jsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MultiSelectAutocomplete } from '@crayons/MultiSelectAutocomplete/MultiSelectAutocomplete';
|
||||
|
||||
/**
|
||||
* UsernameInput — produces a field that can autocomplete usernames
|
||||
*
|
||||
* @param {Function} fetchSuggestions Callback to sync selections to article form state
|
||||
* @param {string} defaultValue Comma separated list of any currently user IDs
|
||||
*/
|
||||
export const UsernameInput = ({
|
||||
fetchSuggestions,
|
||||
defaultValue,
|
||||
inputId,
|
||||
labelText,
|
||||
placeholder,
|
||||
maxSelections,
|
||||
handleSelectionsChanged,
|
||||
}) => {
|
||||
const onSelectionsChanged = function (selections) {
|
||||
const ids = selections.map((item) => item.id).join(', ');
|
||||
handleSelectionsChanged?.(ids);
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectAutocomplete
|
||||
allowUserDefinedSelections={false}
|
||||
showLabel={false}
|
||||
border={true}
|
||||
inputId={inputId}
|
||||
labelText={labelText}
|
||||
placeholder={placeholder}
|
||||
maxSelections={maxSelections}
|
||||
defaultValue={defaultValue}
|
||||
fetchSuggestions={fetchSuggestions}
|
||||
onSelectionsChanged={onSelectionsChanged}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
UsernameInput.propTypes = {
|
||||
fetchSuggestions: PropTypes.func.isRequired,
|
||||
defaultValue: PropTypes.string,
|
||||
inputId: PropTypes.string,
|
||||
labelText: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
maxSelections: PropTypes.string,
|
||||
handleSelectionsChanged: PropTypes.func.isRequired,
|
||||
};
|
||||
79
app/javascript/shared/components/__tests__/UserStore.test.js
Normal file
79
app/javascript/shared/components/__tests__/UserStore.test.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import fetch from 'jest-fetch-mock';
|
||||
import { UserStore } from '../UserStore';
|
||||
|
||||
global.fetch = fetch;
|
||||
|
||||
function fakeUsers() {
|
||||
return JSON.stringify([
|
||||
{ name: 'Alice', username: 'alice', id: 1 },
|
||||
{ name: 'Bob', username: 'bob', id: 2 },
|
||||
{ name: 'Charlie', username: 'charlie', id: 3 },
|
||||
{ name: 'Almost Alice', username: 'almostalice', id: 4 },
|
||||
]);
|
||||
}
|
||||
|
||||
describe('UserStore', () => {
|
||||
beforeEach(() => {
|
||||
fetch.resetMocks();
|
||||
fetch.mockResponse(fakeUsers());
|
||||
});
|
||||
|
||||
test('initializes with an empty user list', () => {
|
||||
const subject = new UserStore();
|
||||
expect(subject.users).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('initializes unfetched', () => {
|
||||
const subject = new UserStore();
|
||||
expect(subject.wasFetched).toBeFalsy();
|
||||
});
|
||||
|
||||
test('fetch from a given url', () => {
|
||||
new UserStore().fetch('/path/to/the/users');
|
||||
expect(fetch).toHaveBeenCalledWith('/path/to/the/users');
|
||||
});
|
||||
|
||||
test('only fetches once', async () => {
|
||||
const subject = new UserStore();
|
||||
await subject.fetch('/path/to/the/users');
|
||||
expect(subject.wasFetched).toBeTruthy();
|
||||
await subject.fetch('/path/to/the/users');
|
||||
await subject.fetch('/path/to/the/users');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('return a sub-set of users matching given IDs', async () => {
|
||||
const subject = new UserStore();
|
||||
await subject.fetch('/path/to/the/users');
|
||||
expect(subject.matchingIds(['1', '4'])).toStrictEqual([
|
||||
{ name: 'Alice', username: 'alice', id: 1 },
|
||||
{ name: 'Almost Alice', username: 'almostalice', id: 4 },
|
||||
]);
|
||||
expect(subject.matchingIds(['2', '3'])).toStrictEqual([
|
||||
{ name: 'Bob', username: 'bob', id: 2 },
|
||||
{ name: 'Charlie', username: 'charlie', id: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('return a sub-set of users matching search term', async () => {
|
||||
const subject = new UserStore();
|
||||
await subject.fetch('/path/to/the/users');
|
||||
expect(subject.search('alice')).toStrictEqual([
|
||||
{ name: 'Alice', username: 'alice', id: 1 },
|
||||
{ name: 'Almost Alice', username: 'almostalice', id: 4 },
|
||||
]);
|
||||
expect(subject.search('stal')).toStrictEqual([
|
||||
{ name: 'Almost Alice', username: 'almostalice', id: 4 },
|
||||
]);
|
||||
expect(subject.search('david')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('search with an exception', async () => {
|
||||
const subject = new UserStore();
|
||||
await subject.fetch('/path/to/the/users');
|
||||
expect(subject.search('a', { except: '3' })).toStrictEqual([
|
||||
{ name: 'Alice', username: 'alice', id: 1 },
|
||||
{ name: 'Almost Alice', username: 'almostalice', id: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { h } from 'preact';
|
||||
import { render } from '@testing-library/preact';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { UsernameInput } from '../UsernameInput';
|
||||
|
||||
function fakeUsers() {
|
||||
return [
|
||||
{ name: 'Alice', username: 'alice', id: 1 },
|
||||
{ name: 'Bob', username: 'bob', id: 2 },
|
||||
{ name: 'Charlie', username: 'charlie', id: 3 },
|
||||
{ name: 'Almost Alice', username: 'almostalice', id: 4 },
|
||||
];
|
||||
}
|
||||
|
||||
describe('<UsernameInput />', () => {
|
||||
it('renders the default component', () => {
|
||||
const { container } = render(
|
||||
<UsernameInput
|
||||
labelText="Example label"
|
||||
fetchSuggestions={() => {}}
|
||||
handleSelectionsChanged={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.innerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls handleSelectionsChanged with user IDs', async () => {
|
||||
const fakeHandler = jest.fn();
|
||||
|
||||
const { getByLabelText, queryByRole } = render(
|
||||
<UsernameInput
|
||||
labelText="Enter username"
|
||||
fetchSuggestions={fakeUsers}
|
||||
handleSelectionsChanged={fakeHandler}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = getByLabelText('Enter username');
|
||||
input.focus();
|
||||
await userEvent.type(input, 'Bob,');
|
||||
await userEvent.type(input, 'Charlie,');
|
||||
|
||||
expect(queryByRole('button', { name: 'Edit example' })).toBeNull();
|
||||
expect(fakeHandler).toHaveBeenCalledWith('2, 3');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<UsernameInput /> renders the default component 1`] = `"<span aria-hidden=\\"true\\" class=\\"absolute pointer-events-none opacity-0 p-2\\"></span><label id=\\"multi-select-label\\" class=\\"screen-reader-only\\">Example label</label><span id=\\"input-description\\" class=\\"screen-reader-only\\"></span><div class=\\"screen-reader-only\\"><p>Selected items:</p><ul aria-live=\\"assertive\\" aria-atomic=\\"false\\" aria-relevant=\\"additions removals\\" class=\\"screen-reader-only list-none\\"></ul></div><div class=\\"c-autocomplete--multi relative\\"><div role=\\"combobox\\" aria-haspopup=\\"listbox\\" aria-expanded=\\"false\\" aria-owns=\\"listbox1\\" class=\\"c-autocomplete--multi__wrapper-border crayons-textfield flex items-center cursor-text\\"><ul id=\\"combo-selected\\" class=\\"list-none flex flex-wrap w-100\\"><li style=\\"order: 1;\\" class=\\"self-center\\"><input autocomplete=\\"off\\" aria-autocomplete=\\"list\\" aria-labelledby=\\"multi-select-label selected-items-list\\" aria-describedby=\\"input-description\\" aria-disabled=\\"false\\" type=\\"text\\" placeholder=\\"Add...\\" class=\\"c-autocomplete--multi__input\\"></li></ul></div></div>"`;
|
||||
7
app/javascript/utilities/document_ready.js
Normal file
7
app/javascript/utilities/document_ready.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Document.prototype.ready = new Promise((resolve) => {
|
||||
if (document.readyState !== 'loading') {
|
||||
return resolve();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => resolve());
|
||||
return null;
|
||||
});
|
||||
|
|
@ -577,6 +577,10 @@ class Article < ApplicationRecord
|
|||
hotness_score: BlackBox.article_hotness_score(self))
|
||||
end
|
||||
|
||||
def co_author_ids_list
|
||||
co_author_ids.join(", ")
|
||||
end
|
||||
|
||||
def co_author_ids_list=(list_of_co_author_ids)
|
||||
self.co_author_ids = list_of_co_author_ids.split(",").map(&:strip)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ module Articles
|
|||
class Attributes
|
||||
ATTRIBUTES = %i[archived body_markdown canonical_url description
|
||||
edited_at main_image organization_id user_id published
|
||||
title video_thumbnail_url published_at].freeze
|
||||
title video_thumbnail_url published_at co_author_ids_list].freeze
|
||||
|
||||
attr_reader :attributes, :article_user
|
||||
|
||||
|
|
|
|||
|
|
@ -116,11 +116,26 @@
|
|||
<% if organization && org_admin %>
|
||||
<%= form_for(article, html: { class: "mt-4 pt-4 border-0 border-t-1 border-color-base-10 border-solid" }) do |f| %>
|
||||
<input type="hidden" name="destination" value="/dashboard/organization/<%= organization.id %>" />
|
||||
|
||||
<div class="crayons-field mb-4">
|
||||
<label for="user_id" class="crayons-field__label"><%= t("views.dashboard.article.author") %></label>
|
||||
<%= f.select :user_id, options_for_select(organization.users.pluck(:name, :id), article.user_id), {}, { class: "crayons-select" } %>
|
||||
<%= f.hidden_field :from_dashboard, value: 1 %>
|
||||
</div>
|
||||
|
||||
<div class="crayons-field mb-4">
|
||||
<label for="co_author_ids" class="crayons-field__label"><%= t("views.dashboard.article.co_authors") %></label>
|
||||
<div class="crayons-field">
|
||||
<%= f.text_field :co_author_ids_list,
|
||||
id: "article_#{article.id}_co_author_ids_list",
|
||||
class: "article_org_co_author_ids_list",
|
||||
data: {
|
||||
fetch_users: organization_members_path(organization.username, format: "json")
|
||||
} %>
|
||||
</div>
|
||||
<%= f.hidden_field :from_dashboard, value: 1 %>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="crayons-btn"><%= t("views.dashboard.article.save") %></button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -69,3 +69,4 @@
|
|||
</main>
|
||||
|
||||
<%= javascript_packs_with_chunks_tag "archivedPostFilters", defer: true %>
|
||||
<%= javascript_packs_with_chunks_tag "dashboards/convertCoauthorIdsToUsernameInputs", defer: true %>
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ en:
|
|||
archive: Archive post
|
||||
unarchive: Unarchive post
|
||||
author: Author
|
||||
co_authors: Co-authors
|
||||
author_is: 'AUTHOR: '
|
||||
save: Save
|
||||
submit: Submit author change
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ fr:
|
|||
archive: Archive post
|
||||
unarchive: Unarchive post
|
||||
author: Author
|
||||
co_authors: Co-authors
|
||||
author_is: 'AUTHOR: '
|
||||
save: Save
|
||||
submit: Submit author change
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ module.exports = {
|
|||
// This exclusion avoids running coverage on Barrel files, https://twitter.com/housecor/status/981558704708472832
|
||||
'!app/javascript/**/index.js',
|
||||
'!app/javascript/packs/**/*.js', // avoids running coverage on webpacker pack files
|
||||
'app/javascript/packs/dashboards/*.js', // try opting-in for jest coverage?
|
||||
'!**/__tests__/**',
|
||||
'!**/__stories__/**',
|
||||
'!app/javascript/storybook-static/**/*.js',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue