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:
Joshua Wehner 2023-09-15 14:12:41 +02:00 committed by GitHub
parent 612c402887
commit 6fe9d7d7a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 552 additions and 4 deletions

View file

@ -328,8 +328,9 @@ class ArticlesController < ApplicationController
# NOTE: the organization logic is still a little counter intuitive but this should # NOTE: the organization logic is still a little counter intuitive but this should
# fix the bug <https://github.com/forem/forem/issues/2871> # 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 << :user_id
allowed_params << :co_author_ids_list
elsif params["article"]["organization_id"] && allowed_to_change_org_id? elsif params["article"]["organization_id"] && allowed_to_change_org_id?
# change the organization of the article only if explicitly asked to do so # change the organization of the article only if explicitly asked to do so
allowed_params << :organization_id allowed_params << :organization_id

View file

@ -97,6 +97,11 @@ class OrganizationsController < ApplicationController
def members def members
@organization = Organization.find_by(slug: params[:slug]) @organization = Organization.find_by(slug: params[:slug])
@members = @organization.users @members = @organization.users
respond_to do |format|
format.json { render json: @members.to_json(only: %i[id name username]) }
format.html
end
end end
private private

View file

@ -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>
"
`;

View file

@ -1,4 +1,4 @@
import { getBillboard } from '../billboard'; import { getBillboard } from '../packs/billboard';
describe('getBillboard', () => { describe('getBillboard', () => {
beforeEach(() => { beforeEach(() => {

View file

@ -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('');
});
});
});

View file

@ -598,7 +598,12 @@ export const MultiSelectAutocomplete = ({
className={`c-autocomplete--multi__wrapper${ className={`c-autocomplete--multi__wrapper${
border ? '-border crayons-textfield' : ' border-none p-0' border ? '-border crayons-textfield' : ' border-none p-0'
} flex items-center cursor-text`} } 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"> <ul id="combo-selected" className="list-none flex flex-wrap w-100">
{allSelectedItemElements} {allSelectedItemElements}

View file

@ -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();
});

View 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;
}
}

View 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,
};

View 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 },
]);
});
});

View file

@ -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');
});
});

View file

@ -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>"`;

View file

@ -0,0 +1,7 @@
Document.prototype.ready = new Promise((resolve) => {
if (document.readyState !== 'loading') {
return resolve();
}
document.addEventListener('DOMContentLoaded', () => resolve());
return null;
});

View file

@ -577,6 +577,10 @@ class Article < ApplicationRecord
hotness_score: BlackBox.article_hotness_score(self)) hotness_score: BlackBox.article_hotness_score(self))
end end
def co_author_ids_list
co_author_ids.join(", ")
end
def co_author_ids_list=(list_of_co_author_ids) def co_author_ids_list=(list_of_co_author_ids)
self.co_author_ids = list_of_co_author_ids.split(",").map(&:strip) self.co_author_ids = list_of_co_author_ids.split(",").map(&:strip)
end end

View file

@ -2,7 +2,7 @@ module Articles
class Attributes class Attributes
ATTRIBUTES = %i[archived body_markdown canonical_url description ATTRIBUTES = %i[archived body_markdown canonical_url description
edited_at main_image organization_id user_id published 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 attr_reader :attributes, :article_user

View file

@ -116,11 +116,26 @@
<% if organization && org_admin %> <% 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| %> <%= 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 %>" /> <input type="hidden" name="destination" value="/dashboard/organization/<%= organization.id %>" />
<div class="crayons-field mb-4"> <div class="crayons-field mb-4">
<label for="user_id" class="crayons-field__label"><%= t("views.dashboard.article.author") %></label> <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.select :user_id, options_for_select(organization.users.pluck(:name, :id), article.user_id), {}, { class: "crayons-select" } %>
<%= f.hidden_field :from_dashboard, value: 1 %> <%= f.hidden_field :from_dashboard, value: 1 %>
</div> </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> <button type="submit" class="crayons-btn"><%= t("views.dashboard.article.save") %></button>
<% end %> <% end %>
<% end %> <% end %>

View file

@ -69,3 +69,4 @@
</main> </main>
<%= javascript_packs_with_chunks_tag "archivedPostFilters", defer: true %> <%= javascript_packs_with_chunks_tag "archivedPostFilters", defer: true %>
<%= javascript_packs_with_chunks_tag "dashboards/convertCoauthorIdsToUsernameInputs", defer: true %>

View file

@ -94,6 +94,7 @@ en:
archive: Archive post archive: Archive post
unarchive: Unarchive post unarchive: Unarchive post
author: Author author: Author
co_authors: Co-authors
author_is: 'AUTHOR: ' author_is: 'AUTHOR: '
save: Save save: Save
submit: Submit author change submit: Submit author change

View file

@ -94,6 +94,7 @@ fr:
archive: Archive post archive: Archive post
unarchive: Unarchive post unarchive: Unarchive post
author: Author author: Author
co_authors: Co-authors
author_is: 'AUTHOR: ' author_is: 'AUTHOR: '
save: Save save: Save
submit: Submit author change submit: Submit author change

View file

@ -14,6 +14,7 @@ module.exports = {
// This exclusion avoids running coverage on Barrel files, https://twitter.com/housecor/status/981558704708472832 // This exclusion avoids running coverage on Barrel files, https://twitter.com/housecor/status/981558704708472832
'!app/javascript/**/index.js', '!app/javascript/**/index.js',
'!app/javascript/packs/**/*.js', // avoids running coverage on webpacker pack files '!app/javascript/packs/**/*.js', // avoids running coverage on webpacker pack files
'app/javascript/packs/dashboards/*.js', // try opting-in for jest coverage?
'!**/__tests__/**', '!**/__tests__/**',
'!**/__stories__/**', '!**/__stories__/**',
'!app/javascript/storybook-static/**/*.js', '!app/javascript/storybook-static/**/*.js',