feat(ui): another go at gallery (#3791)

* feat(ui): migrate listImages to RTK query using createEntityAdapter

- see comments in `endpoints/images.ts` for explanation of the caching
- so far, only manually updating `all` images when new image is generated. no other manual cache updates are implemented, but will be needed.
- fixed some weirdness with loading state components (like the spinners in gallery)
- added `useThumbnailFallback` for `IAIDndImage`, this displays the tiny webp thumbnail while the full-size images load
- comment out some old thunk related stuff in gallerySlice, which is no longer needed

* feat(ui): add manual cache updates for board changes (wip)

- update RTK Query caches when adding/removing single image to/from board
- work more on migrating all image-related operations to RTK Query

* update AddImagesToBoardContext so that it works when user uses context menu + modal

* handle case where no image is selected

* get assets working for main list and boards - dnd only

* feat(ui): migrate image uploads to RTK Query

- minor refactor of `ImageUploader` and `useImageUploadButton` hooks, simplify some logic
- style filesystem upload overlay to match existing UI
- replace all old `imageUploaded` thunks with `uploadImage` RTK Query calls, update associated logic including canvas related uploads
- simplify `PostUploadAction`s that only need to display user input

* feat(ui): remove `receivedPageOfImages` thunks

* feat(ui): remove `receivedImageUrls` thunk

* feat(ui): finish removing all images thunks

stuff now broken:
- image usage
- delete board images
- on first load, no image selected

* feat(ui): simplify `updateImage` cache manipulation

- we don't actually ever change categories, so we can remove a lot of logic

* feat(ui): simplify canvas autosave

- instead of using a network request to set the canvas generation as not intermediate, we can just do that in the graph

* feat(ui): simplify & handle edge cases in cache updates

* feat(db, api): support `board_id='none'` for `get_many` images queries

This allows us to get all images that are not on a board.

* chore(ui): regen types

* feat(ui): add `All Assets`, `No Board` boards

Restructure boards:
- `all images` is all images
- `all assets` is all assets
- `no board` is all images/assets without a board set
- user boards may have images and assets

Update caching logic
- much simpler without every board having sub-views of images and assets
- update drag and drop operations for all possible interactions

* chore(ui): regen types

* feat(ui): move download to top of context menu

* feat(ui): improve drop overlay styles

* fix(ui): fix image not selected on first load

- listen for first load of all images board, then select the first image

* feat(ui): refactor board deletion

api changes:
- add route to list all image names for a board. this is required to handle board + image deletion. we need to know every image in the board to determine the image usage across the app. this is fetched only when the delete board and images modal is opened so it's as efficient as it can be.
- update the delete board route to respond with a list of deleted `board_images` and `images`, as image names. this is needed to perform accurate clientside state & cache updates after deleting.

db changes:
- remove unused `board_images` service method to get paginated images dtos for a board. this is now done thru the list images endpoint & images service. needs a small logic change on `images.delete_images_on_board`

ui changes:
- simplify the delete board modal - no context, just minor prop drilling. this is feasible for boards only because the components that need to trigger and manipulate the modal are very close together in the tree
- add cache updates for `deleteBoard` & `deleteBoardAndImages` mutations
- the only thing we cannot do directly is on `deleteBoardAndImages`, update the `No Board` board. we'd need to insert image dtos that we may not have loaded. instead, i am just invalidating the tags for that `listImages` cache. so when you `deleteBoardAndImages`, the `No Board` will re-fetch the initial image limit. i think this is more efficient than e.g. fetching all image dtos to insert then inserting them.
- handle image usage for `deleteBoardAndImages`
- update all (i think/hope) the little bits and pieces in the UI to accomodate these changes

* fix(ui): fix board selection logic

* feat(ui): add delete board modal loading state

* fix(ui): use thumbnails for board cover images

* fix(ui): fix race condition with board selection

when selecting a board that doesn't have any images loaded, we need to wait until the images haveloaded before selecting the first image.

this logic is debounced to ~1000ms.

* feat(ui): name 'No Board' correctly, change icon

* fix(ui): do not cache listAllImageNames query

if we cache it, we can end up with stale image usage during deletion.

we could of course manually update the cache as we are doing elsewhere. but because this is a relatively infrequent network request, i'd like to trade increased cache mgmt complexity here for increased resource usage.

* feat(ui): reduce drag preview opacity, remove border

* fix(ui): fix incorrect queryArg used in `deleteImage` and `updateImage` cache updates

* fix(ui): fix doubled open in new tab

* fix(ui): fix new generations not getting added to 'No Board'

* fix(ui): fix board id not changing on new image when autosave enabled

* fix(ui): context menu when selection is 0

need to revise how context menu is triggered later, when we approach multi select

* fix(ui): fix deleting does not update counts for all images and all assets

* fix(ui): fix all assets board name in boards list collapse button

* fix(ui): ensure we never go under 0 for total board count

* fix(ui): fix text overflow on board names

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
This commit is contained in:
psychedelicious
2023-07-20 02:06:38 +10:00
committed by GitHub
parent 055f5b2d4b
commit 0724eb9e0a
100 changed files with 3059 additions and 2814 deletions

View File

@ -1,6 +1,7 @@
import { OffsetPaginatedResults_ImageDTO_ } from 'services/api/types';
import { ImageDTO, OffsetPaginatedResults_ImageDTO_ } from 'services/api/types';
import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema';
import { BoardId } from 'features/gallery/store/gallerySlice';
type ListBoardImagesArg =
paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] &
@ -45,39 +46,7 @@ export const boardImagesApi = api.injectEndpoints({
return tags;
},
}),
/**
* Board Images Mutations
*/
addImageToBoard: build.mutation<void, AddImageToBoardArg>({
query: ({ board_id, image_name }) => ({
url: `board_images/`,
method: 'POST',
body: { board_id, image_name },
}),
invalidatesTags: (result, error, arg) => [
{ type: 'BoardImage' },
{ type: 'Board', id: arg.board_id },
],
}),
removeImageFromBoard: build.mutation<void, RemoveImageFromBoardArg>({
query: ({ board_id, image_name }) => ({
url: `board_images/`,
method: 'DELETE',
body: { board_id, image_name },
}),
invalidatesTags: (result, error, arg) => [
{ type: 'BoardImage' },
{ type: 'Board', id: arg.board_id },
],
}),
}),
});
export const {
useAddImageToBoardMutation,
useRemoveImageFromBoardMutation,
useListBoardImagesQuery,
} = boardImagesApi;
export const { useListBoardImagesQuery } = boardImagesApi;

View File

@ -1,6 +1,17 @@
import { BoardDTO, OffsetPaginatedResults_BoardDTO_ } from 'services/api/types';
import { Update } from '@reduxjs/toolkit';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import {
BoardDTO,
ImageDTO,
OffsetPaginatedResults_BoardDTO_,
} from 'services/api/types';
import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema';
import { getListImagesUrl, imagesAdapter, imagesApi } from './images';
type ListBoardsArg = NonNullable<
paths['/api/v1/boards/']['get']['parameters']['query']
@ -11,6 +22,9 @@ type UpdateBoardArg =
changes: paths['/api/v1/boards/{board_id}']['patch']['requestBody']['content']['application/json'];
};
type DeleteBoardResult =
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'];
export const boardsApi = api.injectEndpoints({
endpoints: (build) => ({
/**
@ -59,6 +73,16 @@ export const boardsApi = api.injectEndpoints({
},
}),
listAllImageNamesForBoard: build.query<Array<string>, string>({
query: (board_id) => ({
url: `boards/${board_id}/image_names`,
}),
providesTags: (result, error, arg) => [
{ type: 'ImageNameList', id: arg },
],
keepUnusedDataFor: 0,
}),
/**
* Boards Mutations
*/
@ -82,11 +106,92 @@ export const boardsApi = api.injectEndpoints({
{ type: 'Board', id: arg.board_id },
],
}),
deleteBoard: build.mutation<void, string>({
deleteBoard: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }),
invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }],
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg },
// invalidate the 'No Board' cache
{ type: 'ImageList', id: getListImagesUrl({ board_id: 'none' }) },
],
async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) {
/**
* Cache changes for deleteBoard:
* - Update every image in the 'getImageDTO' cache that has the board_id
* - Update every image in the 'All Images' cache that has the board_id
* - Update every image in the 'All Assets' cache that has the board_id
* - Invalidate the 'No Board' cache:
* Ideally we'd be able to insert all deleted images into the cache, but we don't
* have access to the deleted images DTOs - only the names, and a network request
* for all of a board's DTOs could be very large. Instead, we invalidate the 'No Board'
* cache.
*/
try {
const { data } = await queryFulfilled;
const { deleted_board_images } = data;
// update getImageDTO caches
deleted_board_images.forEach((image_id) => {
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_id,
(draft) => {
draft.board_id = undefined;
}
)
);
});
// update 'All Images' & 'All Assets' caches
const queryArgsToUpdate = [
{
categories: IMAGE_CATEGORIES,
},
{
categories: ASSETS_CATEGORIES,
},
];
const updates: Update<ImageDTO>[] = deleted_board_images.map(
(image_name) => ({
id: image_name,
changes: { board_id: undefined },
})
);
queryArgsToUpdate.forEach((queryArgs) => {
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
const oldCount = imagesAdapter
.getSelectors()
.selectTotal(draft);
const newState = imagesAdapter.updateMany(draft, updates);
const newCount = imagesAdapter
.getSelectors()
.selectTotal(newState);
draft.total = Math.max(
draft.total - (oldCount - newCount),
0
);
}
)
);
});
// after deleting a board, select the 'All Images' board
dispatch(boardIdSelected('images'));
} catch {
//no-op
}
},
}),
deleteBoardAndImages: build.mutation<void, string>({
deleteBoardAndImages: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({
url: `boards/${board_id}`,
method: 'DELETE',
@ -94,8 +199,63 @@ export const boardsApi = api.injectEndpoints({
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg },
{ type: 'Image', id: LIST_TAG },
{ type: 'ImageList', id: getListImagesUrl({ board_id: 'none' }) },
],
async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) {
/**
* Cache changes for deleteBoardAndImages:
* - ~~Remove every image in the 'getImageDTO' cache that has the board_id~~
* This isn't actually possible, you cannot remove cache entries with RTK Query.
* Instead, we rely on the UI to remove all components that use the deleted images.
* - Remove every image in the 'All Images' cache that has the board_id
* - Remove every image in the 'All Assets' cache that has the board_id
*/
try {
const { data } = await queryFulfilled;
const { deleted_images } = data;
// update 'All Images' & 'All Assets' caches
const queryArgsToUpdate = [
{
categories: IMAGE_CATEGORIES,
},
{
categories: ASSETS_CATEGORIES,
},
];
queryArgsToUpdate.forEach((queryArgs) => {
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
const oldCount = imagesAdapter
.getSelectors()
.selectTotal(draft);
const newState = imagesAdapter.removeMany(
draft,
deleted_images
);
const newCount = imagesAdapter
.getSelectors()
.selectTotal(newState);
draft.total = Math.max(
draft.total - (oldCount - newCount),
0
);
}
)
);
});
// after deleting a board, select the 'All Images' board
dispatch(boardIdSelected('images'));
} catch {
//no-op
}
},
}),
}),
});
@ -107,4 +267,5 @@ export const {
useUpdateBoardMutation,
useDeleteBoardMutation,
useDeleteBoardAndImagesMutation,
useListAllImageNamesForBoardQuery,
} = boardsApi;

View File

@ -1,7 +1,27 @@
import { EntityState, createEntityAdapter } from '@reduxjs/toolkit';
import { PatchCollection } from '@reduxjs/toolkit/dist/query/core/buildThunks';
import { dateComparator } from 'common/util/dateComparator';
import {
ASSETS_CATEGORIES,
BoardId,
IMAGE_CATEGORIES,
} from 'features/gallery/store/gallerySlice';
import { omit } from 'lodash-es';
import queryString from 'query-string';
import { ApiFullTagDescription, api } from '..';
import { components } from '../schema';
import { ImageDTO } from '../types';
import { components, paths } from '../schema';
import {
ImageCategory,
ImageChanges,
ImageDTO,
OffsetPaginatedResults_ImageDTO_,
PostUploadAction,
} from '../types';
import { getCacheAction } from './util';
export type ListImagesArgs = NonNullable<
paths['/api/v1/images/']['get']['parameters']['query']
>;
/**
* This is an unsafe type; the object inside is not guaranteed to be valid.
@ -11,11 +31,102 @@ export type UnsafeImageMetadata = {
graph: NonNullable<components['schemas']['Graph']>;
};
export type ImageCache = EntityState<ImageDTO> & { total: number };
// The adapter is not actually the data store - it just provides helper functions to interact
// with some other store of data. We will use the RTK Query cache as that store.
export const imagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
// We want to also store the images total in the cache. When we initialize the cache state,
// we will provide this type arg so the adapter knows we want the total.
export type AdditionalImagesAdapterState = { total: number };
// Create selectors for the adapter.
export const imagesSelectors = imagesAdapter.getSelectors();
// Helper to create the url for the listImages endpoint. Also we use it to create the cache key.
export const getListImagesUrl = (queryArgs: ListImagesArgs) =>
`images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`;
export const SYSTEM_BOARDS = ['images', 'assets', 'no_board', 'batch'];
export const imagesApi = api.injectEndpoints({
endpoints: (build) => ({
/**
* Image Queries
*/
listImages: build.query<
EntityState<ImageDTO> & { total: number },
ListImagesArgs
>({
query: (queryArgs) => ({
// Use the helper to create the URL.
url: getListImagesUrl(queryArgs),
method: 'GET',
}),
providesTags: (result, error, { board_id, categories }) => [
// Make the tags the same as the cache key
{ type: 'ImageList', id: getListImagesUrl({ board_id, categories }) },
],
serializeQueryArgs: ({ queryArgs }) => {
// Create cache & key based on board_id and categories - skip the other args.
// Offset is the size of the cache, and limit is always the same. Both are provided by
// the consumer of the query.
const { board_id, categories } = queryArgs;
// Just use the same fn used to create the url; it makes an understandable cache key.
// This cache key is the same for any combo of board_id and categories, doesn't change
// when offset & limit change.
const cacheKey = getListImagesUrl({ board_id, categories });
return cacheKey;
},
transformResponse(response: OffsetPaginatedResults_ImageDTO_) {
const { total, items: images } = response;
// Use the adapter to convert the response to the right shape, and adding the new total.
// The trick is to just provide an empty state and add the images array to it. This returns
// a properly shaped EntityState.
return imagesAdapter.addMany(
imagesAdapter.getInitialState<AdditionalImagesAdapterState>({
total,
}),
images
);
},
merge: (cache, response) => {
// Here we actually update the cache. `response` here is the output of `transformResponse`
// above. In a similar vein to `transformResponse`, we can use the imagesAdapter to get
// things in the right shape. Also update the total image count.
imagesAdapter.addMany(cache, imagesSelectors.selectAll(response));
cache.total = response.total;
},
forceRefetch({ currentArg, previousArg }) {
// Refetch when the offset changes (which means we are on a new page).
return currentArg?.offset !== previousArg?.offset;
},
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
// update the `getImageDTO` cache for each image
imagesSelectors.selectAll(data).forEach((imageDTO) => {
dispatch(
imagesApi.util.upsertQueryData(
'getImageDTO',
imageDTO.image_name,
imageDTO
)
);
});
} catch {
// no-op
}
},
// 24 hours - reducing this to a few minutes would reduce memory usage.
keepUnusedDataFor: 86400,
}),
getImageDTO: build.query<ImageDTO, string>({
query: (image_name) => ({ url: `images/${image_name}` }),
providesTags: (result, error, arg) => {
@ -40,7 +151,480 @@ export const imagesApi = api.injectEndpoints({
clearIntermediates: build.mutation({
query: () => ({ url: `images/clear-intermediates`, method: 'POST' }),
}),
deleteImage: build.mutation<void, ImageDTO>({
query: ({ image_name }) => ({
url: `images/${image_name}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Image', id: arg.image_name },
],
async onQueryStarted(imageDTO, { dispatch, queryFulfilled }) {
/**
* Cache changes for deleteImage:
* - Remove from "All Images"
* - Remove from image's `board_id` if it has one, or "No Board" if not
* - Remove from "Batch"
*/
const { image_name, board_id, image_category } = imageDTO;
// Figure out the `listImages` caches that we need to update
// That means constructing the possible query args that are serialized into the cache key...
const removeFromCacheKeys: ListImagesArgs[] = [];
const categories = IMAGE_CATEGORIES.includes(image_category)
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// All Images board (e.g. no board)
removeFromCacheKeys.push({ categories });
// Board specific
if (board_id) {
removeFromCacheKeys.push({ board_id });
} else {
// TODO: No Board
}
// TODO: Batch
const patches: PatchCollection[] = [];
removeFromCacheKeys.forEach((cacheKey) => {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
cacheKey,
(draft) => {
imagesAdapter.removeOne(draft, image_name);
draft.total = Math.max(draft.total - 1, 0);
}
)
)
);
});
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
updateImage: build.mutation<
ImageDTO,
{
imageDTO: ImageDTO;
// For now, we will not allow image categories to change
changes: Omit<ImageChanges, 'image_category'>;
}
>({
query: ({ imageDTO, changes }) => ({
url: `images/${imageDTO.image_name}`,
method: 'PATCH',
body: changes,
}),
invalidatesTags: (result, error, { imageDTO }) => [
{ type: 'Image', id: imageDTO.image_name },
],
async onQueryStarted(
{ imageDTO: oldImageDTO, changes: _changes },
{ dispatch, queryFulfilled, getState }
) {
// TODO: Should we handle changes to boards via this mutation? Seems reasonable...
// let's be extra-sure we do not accidentally change categories
const changes = omit(_changes, 'image_category');
/**
* Cache changes for `updateImage`:
* - Update the ImageDTO
* - Update the image in "All Images" board:
* - IF it is in the date range represented by the cache:
* - add the image IF it is not already in the cache & update the total
* - ELSE update the image IF it is already in the cache
* - IF the image has a board:
* - Update the image in it's own board
* - ELSE Update the image in the "No Board" board (TODO)
*/
const patches: PatchCollection[] = [];
const { image_name, board_id, image_category } = oldImageDTO;
const categories = IMAGE_CATEGORIES.includes(image_category)
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// TODO: No Board
// Update `getImageDTO` cache
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_name,
(draft) => {
Object.assign(draft, changes);
}
)
)
);
// Update the "All Image" or "All Assets" board
const queryArgsToUpdate: ListImagesArgs[] = [{ categories }];
if (board_id) {
// We also need to update the user board
queryArgsToUpdate.push({ board_id });
}
queryArgsToUpdate.forEach((queryArg) => {
const { data } = imagesApi.endpoints.listImages.select(queryArg)(
getState()
);
const cacheAction = getCacheAction(data, oldImageDTO);
if (['update', 'add'].includes(cacheAction)) {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArg,
(draft) => {
// One of the common changes is to make a canvas intermediate a non-intermediate,
// i.e. save a canvas image to the gallery.
// If that was the change, need to add the image to the cache instead of updating
// the existing cache entry.
if (
changes.is_intermediate === false ||
cacheAction === 'add'
) {
// add it to the cache
imagesAdapter.addOne(draft, {
...oldImageDTO,
...changes,
});
draft.total += 1;
} else if (cacheAction === 'update') {
// just update it
imagesAdapter.updateOne(draft, {
id: image_name,
changes,
});
}
}
)
)
);
}
});
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
uploadImage: build.mutation<
ImageDTO,
{
file: File;
image_category: ImageCategory;
is_intermediate: boolean;
postUploadAction?: PostUploadAction;
session_id?: string;
}
>({
query: ({ file, image_category, is_intermediate, session_id }) => {
const formData = new FormData();
formData.append('file', file);
return {
url: `images/`,
method: 'POST',
body: formData,
params: {
image_category,
is_intermediate,
session_id,
},
};
},
async onQueryStarted(
{ file, image_category, is_intermediate, postUploadAction },
{ dispatch, queryFulfilled }
) {
try {
const { data: imageDTO } = await queryFulfilled;
if (imageDTO.is_intermediate) {
// Don't add it to anything
return;
}
// Add the image to the "All Images" / "All Assets" board
const queryArg = {
categories: IMAGE_CATEGORIES.includes(image_category)
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES,
};
dispatch(
imagesApi.util.updateQueryData('listImages', queryArg, (draft) => {
imagesAdapter.addOne(draft, imageDTO);
draft.total = draft.total + 1;
})
);
} catch {
// no-op
}
},
}),
addImageToBoard: build.mutation<
void,
{ board_id: BoardId; imageDTO: ImageDTO }
>({
query: ({ board_id, imageDTO }) => {
const { image_name } = imageDTO;
return {
url: `board_images/`,
method: 'POST',
body: { board_id, image_name },
};
},
invalidatesTags: (result, error, arg) => [
{ type: 'BoardImage' },
{ type: 'Board', id: arg.board_id },
],
async onQueryStarted(
{ board_id, imageDTO: oldImageDTO },
{ dispatch, queryFulfilled, getState }
) {
/**
* Cache changes for addImageToBoard:
* - Remove from "No Board"
* - Remove from `old_board_id` if it has one
* - Add to new `board_id`
* - IF the image's `created_at` is within the range of the board's cached images
* - OR the board cache has length of 0 or 1
* - Update the `total` for each board whose cache is updated
* - Update the ImageDTO
*
* TODO: maybe total should just be updated in the boards endpoints?
*/
const { image_name, board_id: old_board_id } = oldImageDTO;
// Figure out the `listImages` caches that we need to update
const removeFromQueryArgs: ListImagesArgs[] = [];
// TODO: No Board
// TODO: Batch
// Remove from No Board
removeFromQueryArgs.push({ board_id: 'none' });
// Remove from old board
if (old_board_id) {
removeFromQueryArgs.push({ board_id: old_board_id });
}
// Store all patch results in case we need to roll back
const patches: PatchCollection[] = [];
// Updated imageDTO with new board_id
const newImageDTO = { ...oldImageDTO, board_id };
// Update getImageDTO cache
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_name,
(draft) => {
Object.assign(draft, newImageDTO);
}
)
)
);
// Do the "Remove from" cache updates
removeFromQueryArgs.forEach((queryArgs) => {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
// sanity check
if (draft.ids.includes(image_name)) {
imagesAdapter.removeOne(draft, image_name);
draft.total = Math.max(draft.total - 1, 0);
}
}
)
)
);
});
// We only need to add to the cache if the board is not a system board
if (!SYSTEM_BOARDS.includes(board_id)) {
const queryArgs = { board_id };
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(
getState()
);
const cacheAction = getCacheAction(data, oldImageDTO);
if (['add', 'update'].includes(cacheAction)) {
// Do the "Add to" cache updates
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
if (cacheAction === 'add') {
imagesAdapter.addOne(draft, newImageDTO);
draft.total += 1;
} else {
imagesAdapter.updateOne(draft, {
id: image_name,
changes: { board_id },
});
}
}
)
)
);
}
}
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
removeImageFromBoard: build.mutation<void, { imageDTO: ImageDTO }>({
query: ({ imageDTO }) => {
const { board_id, image_name } = imageDTO;
return {
url: `board_images/`,
method: 'DELETE',
body: { board_id, image_name },
};
},
invalidatesTags: (result, error, arg) => [
{ type: 'BoardImage' },
{ type: 'Board', id: arg.imageDTO.board_id },
],
async onQueryStarted(
{ imageDTO },
{ dispatch, queryFulfilled, getState }
) {
/**
* Cache changes for removeImageFromBoard:
* - Add to "No Board"
* - IF the image's `created_at` is within the range of the board's cached images
* - Remove from `old_board_id`
* - Update the ImageDTO
*/
const { image_name, board_id: old_board_id } = imageDTO;
// TODO: Batch
const patches: PatchCollection[] = [];
// Updated imageDTO with new board_id
const newImageDTO = { ...imageDTO, board_id: undefined };
// Update getImageDTO cache
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_name,
(draft) => {
Object.assign(draft, newImageDTO);
}
)
)
);
// Remove from old board
if (old_board_id) {
const oldBoardQueryArgs = { board_id: old_board_id };
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
oldBoardQueryArgs,
(draft) => {
// sanity check
if (draft.ids.includes(image_name)) {
imagesAdapter.removeOne(draft, image_name);
draft.total = Math.max(draft.total - 1, 0);
}
}
)
)
);
}
// Add to "No Board"
const noBoardQueryArgs = { board_id: 'none' };
const { data } = imagesApi.endpoints.listImages.select(
noBoardQueryArgs
)(getState());
// Check if we need to make any cache changes
const cacheAction = getCacheAction(data, imageDTO);
if (['add', 'update'].includes(cacheAction)) {
patches.push(
dispatch(
imagesApi.util.updateQueryData(
'listImages',
noBoardQueryArgs,
(draft) => {
if (cacheAction === 'add') {
imagesAdapter.addOne(draft, imageDTO);
draft.total += 1;
} else {
imagesAdapter.updateOne(draft, {
id: image_name,
changes: { board_id: undefined },
});
}
}
)
)
);
}
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
}
},
}),
}),
});
export const { useGetImageDTOQuery, useGetImageMetadataQuery, useClearIntermediatesMutation } = imagesApi;
export const {
useListImagesQuery,
useLazyListImagesQuery,
useGetImageDTOQuery,
useGetImageMetadataQuery,
useDeleteImageMutation,
useUpdateImageMutation,
useUploadImageMutation,
useAddImageToBoardMutation,
useRemoveImageFromBoardMutation,
useClearIntermediatesMutation,
} = imagesApi;

View File

@ -0,0 +1,51 @@
import { ImageDTO } from '../types';
import { ImageCache, imagesSelectors } from './images';
export const getIsImageInDateRange = (
data: ImageCache | undefined,
imageDTO: ImageDTO
) => {
if (!data) {
return false;
}
const cacheImageDTOS = imagesSelectors.selectAll(data);
if (cacheImageDTOS.length > 1) {
// Images are sorted by `created_at` DESC
// check if the image is newer than the oldest image in the cache
const createdDate = new Date(imageDTO.created_at);
const oldestDate = new Date(
cacheImageDTOS[cacheImageDTOS.length - 1].created_at
);
return createdDate >= oldestDate;
} else if ([0, 1].includes(cacheImageDTOS.length)) {
// if there are only 1 or 0 images in the cache, we consider the image to be in the date range
return true;
}
return false;
};
/**
* Determines the action we should take when an image may need to be added or updated in a cache.
*/
export const getCacheAction = (
data: ImageCache | undefined,
imageDTO: ImageDTO
): 'add' | 'update' | 'none' => {
const isInDateRange = getIsImageInDateRange(data, imageDTO);
const isCacheFullyPopulated = data && data.total === data.ids.length;
const shouldUpdateCache =
Boolean(isInDateRange) || Boolean(isCacheFullyPopulated);
const isImageInCache = data && data.ids.includes(imageDTO.image_name);
if (shouldUpdateCache && isImageInCache) {
return 'update';
}
if (shouldUpdateCache && !isImageInCache) {
return 'add';
}
return 'none';
};