GSOC 2022: Unified Creation Form - BookBrainz

Personal Information

Name: Shubham Gupta

IRC Nick: Shubh

Github: tr1ten

Portfolio: imshubh.ml

Proposal

Project Overview

Currently, Users have to face complex workflow for simple use cases (such as “adding a book”) with lots of repetition of data.

This project aims to design and implement a simple yet powerful editor for such cases which can abstract away bookbrainz specifics from a user by introducing a new book template which will wrap over the existing edition entity with useful defaults and pre-configured relationships.

This will also allow experienced users to create multiple entities and link them in the same workflow.

Features

  • A user will be easily able to add a book through book template, the user can add its author(credits) & publisher(s) either through search or create if not already exist.
  • User can add contents(works) to a book by just specifying type (i.e. work type).
  • All created entities can easily be modified by selecting the required entity in the Created Entities section.
  • Of course this editor is not only limited to book creation, a user will be able to add multiple entities and link them on the same page.

UI Prototype

note: these designs will likely be modified according to suggestions received by community members during the initial phase of the project.

New item Book to access Unified Form Editor
navbar

Unified form will have a Book already created in the Created Entities section.

Book template will have three tabs as described below:

  • Basic: This will include information that users will most likely find on the book’s cover like the publisher’s name.
  • Contents: Works like forewords contained in the book will be added here.
  • Details: Specific details like pages, format, etc will be added here with annotation

This will hide the unnecessary complexities like relationship editor from a user, but that is to say, a user can modify other entities by following the usual workflow as described below.

Other entities like the publisher, author, etc, can also be created individually by selecting the required entity in the Add section.

It will follow a similar workflow to the current entity-editor but with tabs and minor UX improvements.

It will have four tabs namely

  • Basic: Name Section + Entity Section
  • Identifiers
  • Relationships
  • Annotation

This will allow a user to create multiple entities with the familiar workflow of entity-editor.

Example filling of unified form for book creation

From Sidebar, using Created Entities section user can select newly created entity and modify it if needed, or add one using Add section.
After hitting submit, user will be presented with a modal to confirm the changes.

Contents Tab

User can search select work (other fields will be disabled in that case) or create if not exist.

Details Tab

Server Changes

/create & /create/handler will be added to express routes /index with the required controller and helper functions.

import {createEntitesHandler, generateUnifiedProps, unifiedFormMarkup} from '../helpers/entityRouteUtils';

 router.get('/create', isAuthenticated, middleware.loadIdentifierTypes,
 	middleware.loadLanguages,  middleware.loadGenders,  middleware.loadEntityTypes, 
	middleware.loadEditionStatuses, middleware.loadEditionFormats
 	middleware.loadRelationshipTypes, (req, res) => {
 		const props = generateUnifiedProps(req, res, {});
 		const formMarkup = unifiedFormMarkup(props);
 		const {markup, props: updatedProps} = formMarkup;
 		return res.send(target({
 			markup,
 			props: escapeProps(updatedProps),
 			script: '/js/unified-form.js',
 			title: 'Unified  form'
 		}));
 	});

  router.post('/create/handler', isAuthenticatedForHandler, createEntitesHandler);

createEntitesHandler in server helpers/entityRouteUtils will be added with required validator and transformation.

export function createEntitesHandler(req: PassportRequest, res: $Response) {
	const validate = getValidator("unifiedForm");
	if (!validate(req.body)) {
		const err = new error.FormSubmissionError();
		error.sendErrorAsJSON(res, err);
	}
	req.body = entityRoutes.transformForm(req.body);
	return entityRoutes.handleCreateEntities(req, res);
}

handleCreateEntities ( in routes/entity/entity) to create multiple entities each given a new revision, also new revision for adding relationships on them.

export async function handleAddRelationships(
	body,
	editorJSON,
	currentEntity,
	entityType:EntityTypeString,
	orm,
	transacting
) {
	const {Revision} = orm;

	const newRevision = await new Revision({
		authorId: editorJSON.id,
		isMerge: false
	}).save(null, {transacting});
	const relationshipSets = await getNextRelationshipSets(
		orm, transacting, currentEntity, body
	);
	if (_.isEmpty(relationshipSets)) {
		return {};
	}
	// Fetch main entity
	const mainEntity = await fetchOrCreateMainEntity(
		orm, transacting, false, currentEntity.bbid, entityType
	);
		// Fetch all entities that definitely exist
	const otherEntities = await fetchEntitiesForRelationships(
		orm, transacting, currentEntity, relationshipSets
	);
	otherEntities.forEach(entity => { entity.shouldInsert = false; });
	mainEntity.shouldInsert = false;
	const allEntities = [...otherEntities, mainEntity]
		.filter(entity => entity.get('dataId') !== null);
	_.forEach(allEntities, (entityModel) => {
		const bbid: string = entityModel.get('bbid');
		if (_.has(relationshipSets, bbid)) {
			entityModel.set(
				'relationshipSetId',
				// Set to relationshipSet id or null if empty set
				relationshipSets[bbid] && relationshipSets[bbid].get('id')
			);
		}
	});
	const savedMainEntity = await saveEntitiesAndFinishRevision(
		orm, transacting, false, newRevision, mainEntity, allEntities,
		editorJSON.id, body.note
	);
	return savedMainEntity.toJSON();
}

export function handleCreateEntities(
	req: PassportRequest,
	res: $Response
) {
	const {orm}: {orm?: any} = req.app.locals;
	const {Entity, Revision, bookshelf} = orm;
	const editorJSON = req.user;

	const {body}: {body: Record<string, any>} = req;
	let currentEntity: {
		aliasSet: {id: number} | null | undefined,
		annotation: {id: number} | null | undefined,
		bbid: string,
		disambiguation: {id: number} | null | undefined,
		identifierSet: {id: number} | null | undefined,
		type: EntityTypeString
	} | null | undefined;

	const entityEditPromise = bookshelf.transaction(async (transacting) => {
		const savedMainEntities = {};
		const bbidMap = {};
		const allRelationships = [];
		try {
			for (const [entityKey, entityForm] of Object.entries(body)) {
				const entityType = _.upperFirst(entityForm.type);
				allRelationships.push(entityForm.relationships);
				const newEntity = await new Entity({type: entityType}).save(null, {transacting});
				currentEntity = newEntity.toJSON();
				const newRevision = await new Revision({
					authorId: editorJSON.id,
					isMerge: false
				}).save(null, {transacting});
				const changedProps = await getChangedProps(
					orm, transacting, true, currentEntity, entityForm, entityType,
					newRevision, _.pick(entityForm, additionalEntityProps[_.lowerFirst(entityType)])
				);
				const mainEntity = await fetchOrCreateMainEntity(
					orm, transacting, true, currentEntity.bbid, entityType
				);
				mainEntity.shouldInsert = true;
				_.forOwn(changedProps, (value, key) => mainEntity.set(key, value));
				const savedMainEntity = await saveEntitiesAndFinishRevision(
					orm, transacting, true, newRevision, mainEntity, [mainEntity],
					editorJSON.id, entityForm.note
				);
				await savedMainEntity.load('aliasSet.aliases', {transacting});
				await savedMainEntity.refresh({transacting});

				/* fetch and reindex EditionGroups that may have been created automatically by the ORM and not indexed */
				if (savedMainEntity.get('type') === 'Edition') {
					await indexAutoCreatedEditionGroup(orm, savedMainEntity, transacting);
				}
				bbidMap[entityKey] = savedMainEntity.get('bbid');
				savedMainEntities[entityKey] = savedMainEntity.toJSON();
			}
			for (const [index, rels] of allRelationships.entries()) {
				if (!_.isEmpty(rels)) {
					const relationships = rels.map((rel) => (
						{...rel, sourceBbid: _.get(bbidMap, rel.sourceBbid.toString()) ?? rel.sourceBbid,
							 targetBbid: _.get(bbidMap, rel.targetBbid.toString()) ?? rel.targetBbid}
					));
					const cEntity = savedMainEntities[index.toString()];
					const {relationshipSetId} = await handleAddRelationship({relationships}, editorJSON, cEntity, cEntity.type, orm, transacting);
					cEntity.relationshipSetId = relationshipSetId;
				}
			}

			return savedMainEntities;
		}
		catch (err) {
			log.error(err);
			throw err;
		}
	});
	const achievementPromise = entityEditPromise.then(
		(entitiesJSON:Record<string, any>) => {
			const entitiesAchievementsPromise = [];
			for (const entityJSON of Object.values(entitiesJSON)) {
				entitiesAchievementsPromise.push(achievement.processEdit(
					orm, editorJSON.id, entityJSON.revisionId
				)
					.then((unlock) => {
						if (unlock.alert) {
							entityJSON.alert = unlock.alert;
						}
						return entityJSON;
					}));
			}
			return Promise.all(entitiesAchievementsPromise).catch(err => { throw err; });
		}
	);
	return handler.sendPromiseResult(
		res,
		achievementPromise,
		search._bulkIndexEntities
	);
}

Frontend Changes

We will need a new redux store for handling form state, redux state should look something like this

{
	entitySection:{
		activeEntity:{
			nameSection,
			….,
			type,
			id,
			isActive,
			}
		entities:{0:{}}
		}
	submissionSection:{...}
}

Redux Reducer in client/unified-form/helper
Single entity will be using the same entity-editor state with some extra attributes

function entitySectionReducer(state, action) {
	const intermediateState = combineReducers({
		activeEntity: combineReducers(
			{
				active: activeReducer,
				aliasEditor: aliasEditorReducer,
				annotationSection: annotationSectionReducer,
				authorSection: authorSectionReducer,
				id: idReducer,
				identifierEditor: identifierEditorReducer,
				nameSection: nameSectionReducer,
				relationshipSection: relationshipSectionReducer,
				type: typeReducer,
				workSection: workSectionReducer
			}
		),
		entities: entitiesReducer
	})(state, action);
	const finalState = crossSliceReducer(intermediateState, action);
	return finalState;
}
export function createRootReducer() {
	return combineReducers({
		entitySection: entitySectionReducer,
		submissionSection: submissionSectionReducer
	});
}

crossSliceReducer for facilitating interaction between different states

function crossSliceReducer(state, action) {
	const {type, payload} = action;
	switch (type) {
		case SELECT_ENTITY: {
			const newState = crossSliceReducer(state.setIn(['activeEntity', 'active'], false), {type: SAVE_ENTITY});
			return newState.setIn(['entities', payload, 'active'], true).set('activeEntity', newState.getIn(['entities', payload]));
		}
		case SAVE_ENTITY:
			return state.setIn(['entities', state.getIn(['activeEntity', 'id'])], state.get('activeEntity'));
		case REMOVE_ENTITY:
		{
			const activeEntityId = state.getIn(['activeEntity', 'id']);
			const newState = state.deleteIn(['entities', payload]);
			if (payload === activeEntityId) {
				return newState.set('activeEntity', newState.get('entities').first());
			}
			return newState;
		}
		case REMOVEALL_ENTITY:
			return state.set('activeEntity', new Map()).set('entities', new Map());
		default:
			return state;
	}
}

entity-section will manage namely these actions

  • REMOVE_ALL: Remove all entity from “activeEntity” as well as from “entities”
  • REMOVE_ENTITY: Remove specific entity with id
  • SAVE_ENTITY: Save the active entity state in entities
  • SELECT_ENTITY: Select active entity from entities

TIMELINE

Community Bonding Period

I believe this project is similar to the present entity-editor in bookbrainz so I will try to understand it more sincerely.

But my priority would be to design an awesome unified form with the suggestions of community members.

Also, I will be taking on some small tickets in the meantime.

Week 1-2: Designing Phase

Together with my mentor, we will be finalizing the designs for a unified form.
This will need a thorough discussion with mentors and community members for perfecting it!

Week 3: Implementation Phase

I will be working on the routes for submitting multiple entities, also writing thorough unit tests for the same.

Week 4-5-6-7

I will then start with front-end work, I will first work on accommodating the existing entities in the editor.

I will add a unified-form page and create or modify its required components.

I will also be adding the redux reducers and their respective actions.

Week 8

Taking reviews from the mentor, fixing bugs, UX issues, and improving the overall code quality.

Week 9-10-11

I will then start to implement the book template, also I will be adding the default entity-entity relationships so the user doesn’t need to manually link them.

Week 12

Testing unified form thoroughly for bugs, UX issues.

Stretch Goal

  • Adding graph to visualize entities before submission
  • Adding useful validation messages

Detailed information about yourself

My name is Shubham Gupta (aka Shubh), I am a first-year undergrad student pursuing a bachelor’s degree in Information Technology from the National Institute of Technology, Kurushetra. I love working with computers, building stuff to solve real-life problems is what drives me forwards apart from that I’ve always been a big fan of fiction series.

I joined the metabrainz community at the end of November’21

My PRs: Check out

Tell us about the computer(s) you have available for working on your SoC project!

I have a dell Inspiron with 12 gigs of memory and an i3 6th gen processor

When did you first start programming?

In middle school, I wrote my first hello world program in C language.

What type of music do you listen to?

I’m a big fan of artist Alec Benjamin, some of my favorites are outrunning karma and demons.

If applying for a BookBrainz project: what type of books do you read?

I love reading fiction, my favorites include Classroom of elite, Sherlock Holmes.

What aspects of the project you’re applying for the interest you the most?

Designing the intuitive unified form excites me the most

Have you contributed to other Open Source projects?

No, Bookbrainz is my first Open Source project

How much time do you have available, and how would you plan to use it?

I would be able to give approx 45 hours every week to the project.

Do you plan to have a job or study during the summer in conjunction with Summer of Code?

No, I would be able to devote all my time to the project

6 Likes

@mr_monkey When you get time please have a look, would love to hear your suggestions to improve the proposal.
also looking forward to community member’s thoughts on the new form design :smile:

1 Like

Hi!
Thanks for your proposal, and sorry to answer only this late, life got in the way.

In general the proposal is looking great!

I like that the Basic tab is about information you would find on a cover, that’s a smart approach. Why not call the tab Cover instead?

Here are some details that we would need to figure out in the early design process:

The Contents tab should include a way to search for an existing Work, which I don’t see there.
We would also probably find a good way to add some other common relationships when creating a new work such as translator, illustrator…

I’m not sure what purpose the “created entities” section on the right currently has that isn’t covered by a recap screen at the end to confirm data before submission, as we previously talked about (and which I don’t see mentioned in the proposal; it would be good to add it).
The mockup doesn’t make it clear to me how i would use it to add entities, which is what I expect the “+ add work” button in the Contents tab would be for.
I think we can find a way to add entities inline without having that side bar.

One thing that is going to be important is to write tests as the project goes along.
So when you would implement the back-end routes, you would write the tests for it in the continuation before moving to the next task.
It’s easier to write tests with the implementation fresh in mind than at the end, possibly running out of time.

Thanks for your submission !

1 Like

Yeah, my bad we should definitely have a search (or create if not exist) field for contents tab.

I suppose user might want to modify entity that automatically created like publisher, although we can get rid of that for simplicity but i’m afraid it might make this form more restrictive for existing users.

I am planning to show a confirmation modal to list all the entities that will be created after submission.

We will be using search (create if not exist) dropdown to create entity with search query as a primary alias (like publisher shown in the example).

like i said i’m worried if it might make this form restrictive in its usage :frowning:

Yep!

Thanks for all the suggestions, i will be adding these details (with mockups) in the proposal.

2 Likes