@oxy.so/services 11.8.0 → 11.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/lib/commonjs/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/lib/commonjs/assets/assets/fonts/icons/manifest.json +5 -4
- package/lib/commonjs/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/lib/commonjs/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/lib/commonjs/assets/fonts/icons/manifest.json +5 -4
- package/lib/commonjs/index.js +28 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/AccountInvitationAcceptance.js +97 -0
- package/lib/commonjs/ui/components/AccountInvitationAcceptance.js.map +1 -0
- package/lib/commonjs/ui/components/DisplayCurrencySelector.js +49 -0
- package/lib/commonjs/ui/components/DisplayCurrencySelector.js.map +1 -0
- package/lib/commonjs/ui/components/fileManagement/FileViewer.js +2 -1
- package/lib/commonjs/ui/components/fileManagement/FileViewer.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/queryKeys.js +3 -1
- package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/useFileQueries.js +39 -17
- package/lib/commonjs/ui/hooks/queries/useFileQueries.js.map +1 -1
- package/lib/commonjs/ui/hooks/useDisplayCurrency.js +77 -0
- package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +1 -0
- package/lib/commonjs/ui/hooks/useDisplayPrice.js +59 -0
- package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +1 -0
- package/lib/commonjs/ui/icons/subsetGlyphMaps.js +1 -0
- package/lib/commonjs/ui/icons/subsetGlyphMaps.js.map +1 -1
- package/lib/commonjs/ui/navigation/routes.js +1 -0
- package/lib/commonjs/ui/navigation/routes.js.map +1 -1
- package/lib/commonjs/ui/navigation/surfaceRegistry.js +2 -2
- package/lib/commonjs/ui/navigation/surfaceRegistry.js.map +1 -1
- package/lib/commonjs/ui/screens/AccountMembersScreen.js +63 -20
- package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/FileManagementScreen.js +102 -44
- package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/FileTrashScreen.js +235 -0
- package/lib/commonjs/ui/screens/FileTrashScreen.js.map +1 -0
- package/lib/commonjs/ui/screens/PreferencesScreen.js +4 -3
- package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/fileManagement/trashActions.js +147 -0
- package/lib/commonjs/ui/screens/fileManagement/trashActions.js.map +1 -0
- package/lib/commonjs/ui/stores/assetStore.js +22 -7
- package/lib/commonjs/ui/stores/assetStore.js.map +1 -1
- package/lib/module/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/lib/module/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/lib/module/assets/assets/fonts/icons/manifest.json +5 -4
- package/lib/module/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/lib/module/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/lib/module/assets/fonts/icons/manifest.json +5 -4
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/AccountInvitationAcceptance.js +94 -0
- package/lib/module/ui/components/AccountInvitationAcceptance.js.map +1 -0
- package/lib/module/ui/components/DisplayCurrencySelector.js +44 -0
- package/lib/module/ui/components/DisplayCurrencySelector.js.map +1 -0
- package/lib/module/ui/components/fileManagement/FileViewer.js +2 -1
- package/lib/module/ui/components/fileManagement/FileViewer.js.map +1 -1
- package/lib/module/ui/hooks/queries/queryKeys.js +3 -1
- package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
- package/lib/module/ui/hooks/queries/useFileQueries.js +38 -17
- package/lib/module/ui/hooks/queries/useFileQueries.js.map +1 -1
- package/lib/module/ui/hooks/useDisplayCurrency.js +72 -0
- package/lib/module/ui/hooks/useDisplayCurrency.js.map +1 -0
- package/lib/module/ui/hooks/useDisplayPrice.js +56 -0
- package/lib/module/ui/hooks/useDisplayPrice.js.map +1 -0
- package/lib/module/ui/icons/subsetGlyphMaps.js +1 -0
- package/lib/module/ui/icons/subsetGlyphMaps.js.map +1 -1
- package/lib/module/ui/navigation/routes.js +1 -0
- package/lib/module/ui/navigation/routes.js.map +1 -1
- package/lib/module/ui/navigation/surfaceRegistry.js +2 -2
- package/lib/module/ui/navigation/surfaceRegistry.js.map +1 -1
- package/lib/module/ui/screens/AccountMembersScreen.js +63 -20
- package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/module/ui/screens/FileManagementScreen.js +102 -44
- package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/module/ui/screens/FileTrashScreen.js +232 -0
- package/lib/module/ui/screens/FileTrashScreen.js.map +1 -0
- package/lib/module/ui/screens/PreferencesScreen.js +4 -3
- package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
- package/lib/module/ui/screens/fileManagement/trashActions.js +138 -0
- package/lib/module/ui/screens/fileManagement/trashActions.js.map +1 -0
- package/lib/module/ui/stores/assetStore.js +22 -7
- package/lib/module/ui/stores/assetStore.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +5 -0
- package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +3 -0
- package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/fileManagement/FileViewer.d.ts +3 -0
- package/lib/typescript/commonjs/ui/components/fileManagement/FileViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +2 -0
- package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts +22 -6
- package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +11 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +6 -0
- package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
- package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts +1 -0
- package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts +2 -0
- package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/FileTrashScreen.d.ts +14 -0
- package/lib/typescript/commonjs/ui/screens/FileTrashScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/fileManagement/trashActions.d.ts +66 -0
- package/lib/typescript/commonjs/ui/screens/fileManagement/trashActions.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/stores/assetStore.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +5 -0
- package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +3 -0
- package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/fileManagement/FileViewer.d.ts +3 -0
- package/lib/typescript/module/ui/components/fileManagement/FileViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +7 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +2 -0
- package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts +22 -6
- package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +11 -0
- package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +6 -0
- package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +1 -0
- package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
- package/lib/typescript/module/ui/icons/Ionicons.d.ts.map +1 -1
- package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts +1 -0
- package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts +2 -0
- package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/FileTrashScreen.d.ts +14 -0
- package/lib/typescript/module/ui/screens/FileTrashScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/fileManagement/trashActions.d.ts +66 -0
- package/lib/typescript/module/ui/screens/fileManagement/trashActions.d.ts.map +1 -0
- package/lib/typescript/module/ui/stores/assetStore.d.ts.map +1 -1
- package/package.json +7 -5
- package/src/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
- package/src/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
- package/src/assets/fonts/icons/manifest.json +5 -4
- package/src/index.ts +5 -0
- package/src/ui/components/AccountInvitationAcceptance.tsx +90 -0
- package/src/ui/components/DisplayCurrencySelector.tsx +38 -0
- package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +61 -0
- package/src/ui/components/fileManagement/FileViewer.tsx +5 -1
- package/src/ui/hooks/queries/__tests__/fileQueries.test.ts +70 -0
- package/src/ui/hooks/queries/queryKeys.ts +2 -0
- package/src/ui/hooks/queries/useFileQueries.ts +45 -16
- package/src/ui/hooks/useDisplayCurrency.ts +64 -0
- package/src/ui/hooks/useDisplayPrice.ts +40 -0
- package/src/ui/icons/subsetGlyphMaps.ts +1 -0
- package/src/ui/navigation/__tests__/getSurfaceConfig.test.ts +9 -0
- package/src/ui/navigation/routes.ts +2 -0
- package/src/ui/navigation/surfaceRegistry.ts +4 -1
- package/src/ui/screens/AccountMembersScreen.tsx +71 -27
- package/src/ui/screens/FileManagementScreen.tsx +142 -49
- package/src/ui/screens/FileTrashScreen.tsx +228 -0
- package/src/ui/screens/PreferencesScreen.tsx +2 -0
- package/src/ui/screens/fileManagement/__tests__/trashActions.test.ts +177 -0
- package/src/ui/screens/fileManagement/trashActions.ts +161 -0
- package/src/ui/stores/assetStore.ts +24 -13
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file manager's trash decisions (ADR 0043 D5), without the UI: "delete"
|
|
3
|
+
* in the library moves to trash (a blocked file, which cannot be trashed, is
|
|
4
|
+
* deleted); permanent deletion forces past remaining uses only after the
|
|
5
|
+
* person says yes; restore reports a stale trash list instead of failing.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
deletePermanently,
|
|
9
|
+
errorStatus,
|
|
10
|
+
libraryRemovalFor,
|
|
11
|
+
remainingUses,
|
|
12
|
+
removeFromLibrary,
|
|
13
|
+
restoreFromTrash,
|
|
14
|
+
type TrashAssetsApi,
|
|
15
|
+
} from '../trashActions';
|
|
16
|
+
|
|
17
|
+
/** An `OxyApiError`-shaped refusal, as `@oxy.so/core` throws it. */
|
|
18
|
+
function apiError(status: number, code: string, details?: Record<string, unknown>) {
|
|
19
|
+
return Object.assign(new Error(code), { status, code, details });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stillUsed = (remainingLinks: number) =>
|
|
23
|
+
apiError(409, 'CONFLICT', {
|
|
24
|
+
summary: { fileId: 'f', wouldDelete: false, affectedApps: ['a'], remainingLinks, variants: [] },
|
|
25
|
+
message: 'Use ?force=true to delete anyway',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function assetsApi(overrides: Partial<Record<keyof TrashAssetsApi, jest.Mock>> = {}) {
|
|
29
|
+
return {
|
|
30
|
+
trash: overrides.trash ?? jest.fn(async () => ({})),
|
|
31
|
+
restore: overrides.restore ?? jest.fn(async () => ({})),
|
|
32
|
+
delete: overrides.delete ?? jest.fn(async () => ({})),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('libraryRemovalFor', () => {
|
|
37
|
+
it('trashes an active file (or one whose status the server did not say)', () => {
|
|
38
|
+
expect(libraryRemovalFor({ status: 'active' })).toBe('trash');
|
|
39
|
+
expect(libraryRemovalFor({ status: undefined })).toBe('trash');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('deletes a blocked file, which cannot go to trash', () => {
|
|
43
|
+
expect(libraryRemovalFor({ status: 'blocked' })).toBe('delete');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('errorStatus and remainingUses', () => {
|
|
48
|
+
it('read the status from the error or its response', () => {
|
|
49
|
+
expect(errorStatus(apiError(404, 'NOT_FOUND'))).toBe(404);
|
|
50
|
+
expect(errorStatus({ response: { status: 409 } })).toBe(409);
|
|
51
|
+
expect(errorStatus(new Error('network'))).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('count remaining uses only on the 409 that carries a delete summary', () => {
|
|
55
|
+
expect(remainingUses(stillUsed(3))).toBe(3);
|
|
56
|
+
expect(remainingUses(apiError(409, 'FILE_NOT_IN_TRASH', { status: 'active' }))).toBeNull();
|
|
57
|
+
expect(remainingUses(apiError(500, 'INTERNAL'))).toBeNull();
|
|
58
|
+
expect(remainingUses(stillUsed(0))).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('deletePermanently', () => {
|
|
63
|
+
it('deletes without force when nothing uses the file', async () => {
|
|
64
|
+
const assets = assetsApi();
|
|
65
|
+
const confirm = jest.fn();
|
|
66
|
+
await expect(deletePermanently(assets, 'f', confirm)).resolves.toBe('deleted');
|
|
67
|
+
expect(assets.delete).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(assets.delete).toHaveBeenCalledWith('f');
|
|
69
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('asks before forcing past remaining uses, and forces only on a yes', async () => {
|
|
73
|
+
const assets = assetsApi({
|
|
74
|
+
delete: jest.fn(async (_id: string, options?: { force?: boolean }) => {
|
|
75
|
+
if (!options?.force) throw stillUsed(2);
|
|
76
|
+
return {};
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
const confirm = jest.fn(async () => true);
|
|
80
|
+
await expect(deletePermanently(assets, 'f', confirm)).resolves.toBe('deleted');
|
|
81
|
+
expect(confirm).toHaveBeenCalledWith(2);
|
|
82
|
+
expect(assets.delete).toHaveBeenLastCalledWith('f', { force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps the file when the person says no', async () => {
|
|
86
|
+
const assets = assetsApi({
|
|
87
|
+
delete: jest.fn(async () => {
|
|
88
|
+
throw stillUsed(1);
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
await expect(deletePermanently(assets, 'f', async () => false)).resolves.toBe('cancelled');
|
|
92
|
+
expect(assets.delete).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reports a file already gone, and throws any other failure', async () => {
|
|
96
|
+
const gone = assetsApi({
|
|
97
|
+
delete: jest.fn(async () => {
|
|
98
|
+
throw apiError(404, 'NOT_FOUND');
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
await expect(deletePermanently(gone, 'f', jest.fn())).resolves.toBe('gone');
|
|
102
|
+
const broken = assetsApi({
|
|
103
|
+
delete: jest.fn(async () => {
|
|
104
|
+
throw apiError(500, 'INTERNAL');
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
await expect(deletePermanently(broken, 'f', jest.fn())).rejects.toMatchObject({ status: 500 });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('restoreFromTrash', () => {
|
|
112
|
+
it('restores', async () => {
|
|
113
|
+
const assets = assetsApi();
|
|
114
|
+
await expect(restoreFromTrash(assets, 'f')).resolves.toBe('restored');
|
|
115
|
+
expect(assets.restore).toHaveBeenCalledWith('f');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reports a file no longer in trash (restored elsewhere, or blocked) or gone', async () => {
|
|
119
|
+
const notInTrash = assetsApi({
|
|
120
|
+
restore: jest.fn(async () => {
|
|
121
|
+
throw apiError(409, 'FILE_NOT_IN_TRASH', { status: 'blocked' });
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
await expect(restoreFromTrash(notInTrash, 'f')).resolves.toBe('not_in_trash');
|
|
125
|
+
const gone = assetsApi({
|
|
126
|
+
restore: jest.fn(async () => {
|
|
127
|
+
throw apiError(404, 'NOT_FOUND');
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
await expect(restoreFromTrash(gone, 'f')).resolves.toBe('gone');
|
|
131
|
+
const otherConflict = assetsApi({
|
|
132
|
+
restore: jest.fn(async () => {
|
|
133
|
+
throw apiError(409, 'CONFLICT');
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
await expect(restoreFromTrash(otherConflict, 'f')).rejects.toMatchObject({ status: 409 });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('removeFromLibrary', () => {
|
|
141
|
+
it('trashes the active files and deletes the blocked ones; one failure stops nothing', async () => {
|
|
142
|
+
const assets = assetsApi({
|
|
143
|
+
trash: jest.fn(async (id: string) => {
|
|
144
|
+
if (id === 'broken') throw apiError(500, 'INTERNAL');
|
|
145
|
+
return {};
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
const result = await removeFromLibrary(
|
|
149
|
+
assets,
|
|
150
|
+
[
|
|
151
|
+
{ id: 'a', filename: 'a.png', status: 'active' },
|
|
152
|
+
{ id: 'broken', filename: 'b.png', status: 'active' },
|
|
153
|
+
{ id: 'taken-down', filename: 'c.png', status: 'blocked' },
|
|
154
|
+
],
|
|
155
|
+
jest.fn(),
|
|
156
|
+
);
|
|
157
|
+
expect(result).toEqual({ trashed: ['a'], deleted: ['taken-down'], failed: ['broken'] });
|
|
158
|
+
expect(assets.trash).not.toHaveBeenCalledWith('taken-down');
|
|
159
|
+
expect(assets.delete).toHaveBeenCalledWith('taken-down');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('asks about a used blocked file by name, and leaves it out when kept', async () => {
|
|
163
|
+
const assets = assetsApi({
|
|
164
|
+
delete: jest.fn(async () => {
|
|
165
|
+
throw stillUsed(4);
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
const confirm = jest.fn(async () => false);
|
|
169
|
+
const result = await removeFromLibrary(
|
|
170
|
+
assets,
|
|
171
|
+
[{ id: 'x', filename: 'claimed.jpg', status: 'blocked' }],
|
|
172
|
+
confirm,
|
|
173
|
+
);
|
|
174
|
+
expect(confirm).toHaveBeenCalledWith(4, 'claimed.jpg');
|
|
175
|
+
expect(result).toEqual({ trashed: [], deleted: [], failed: [] });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the file manager does when the person removes, restores or deletes a
|
|
3
|
+
* file (ADR 0043 D5). Pure of React and of the UI: every call takes the asset
|
|
4
|
+
* API and the confirmation it may need, so the decisions are testable on their
|
|
5
|
+
* own and the screens only render.
|
|
6
|
+
*
|
|
7
|
+
* - "Delete" in the library MOVES TO TRASH: the file stops being served, is
|
|
8
|
+
* kept, and can be restored from Trash. A `blocked` file (taken down after a
|
|
9
|
+
* content claim, D10) cannot be trashed — it is already served to nobody —
|
|
10
|
+
* so removing it is the permanent delete, which is its owner's to make.
|
|
11
|
+
* - Permanent deletion happens only from Trash (or for a blocked file). The
|
|
12
|
+
* API refuses to delete a file an application still uses (409, with the
|
|
13
|
+
* number of uses); the person is told and decides, and only then is the
|
|
14
|
+
* delete forced.
|
|
15
|
+
*/
|
|
16
|
+
import type { FileMetadata } from '@oxy.so/core';
|
|
17
|
+
|
|
18
|
+
/** The part of `oxyServices.assets` these actions use. */
|
|
19
|
+
export interface TrashAssetsApi {
|
|
20
|
+
trash(fileId: string): Promise<unknown>;
|
|
21
|
+
restore(fileId: string): Promise<unknown>;
|
|
22
|
+
delete(fileId: string, options?: { force?: boolean }): Promise<unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What removing a file from the library does to it. */
|
|
26
|
+
export type LibraryRemoval = 'trash' | 'delete';
|
|
27
|
+
|
|
28
|
+
/** A `blocked` file is deleted (it cannot go to trash); every other is trashed. */
|
|
29
|
+
export function libraryRemovalFor(file: Pick<FileMetadata, 'status'>): LibraryRemoval {
|
|
30
|
+
return file.status === 'blocked' ? 'delete' : 'trash';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The HTTP status of a failed asset call, when it carries one. */
|
|
34
|
+
export function errorStatus(error: unknown): number | undefined {
|
|
35
|
+
if (error && typeof error === 'object') {
|
|
36
|
+
const status = (error as { status?: unknown }).status;
|
|
37
|
+
if (typeof status === 'number') return status;
|
|
38
|
+
const response = (error as { response?: { status?: unknown } }).response;
|
|
39
|
+
if (response && typeof response.status === 'number') return response.status;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The API's machine code of a failed asset call, when it carries one. */
|
|
45
|
+
function errorCode(error: unknown): string | undefined {
|
|
46
|
+
if (error && typeof error === 'object') {
|
|
47
|
+
const code = (error as { code?: unknown }).code;
|
|
48
|
+
if (typeof code === 'string') return code;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How many uses an application still has of a file the API refused to delete
|
|
55
|
+
* (409, `details.summary.remainingLinks`), or `null` when the refusal is not
|
|
56
|
+
* that one.
|
|
57
|
+
*/
|
|
58
|
+
export function remainingUses(error: unknown): number | null {
|
|
59
|
+
if (errorStatus(error) !== 409 || !error || typeof error !== 'object') return null;
|
|
60
|
+
const details = (error as { details?: unknown }).details;
|
|
61
|
+
const summary =
|
|
62
|
+
details && typeof details === 'object' ? (details as { summary?: unknown }).summary : undefined;
|
|
63
|
+
const remaining =
|
|
64
|
+
summary && typeof summary === 'object'
|
|
65
|
+
? (summary as { remainingLinks?: unknown }).remainingLinks
|
|
66
|
+
: undefined;
|
|
67
|
+
return typeof remaining === 'number' && remaining > 0 ? remaining : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Outcome of {@link deletePermanently}. */
|
|
71
|
+
export type PermanentDeleteOutcome = 'deleted' | 'cancelled' | 'gone';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Delete a file for good. When applications still use it the API refuses;
|
|
75
|
+
* `confirmStillUsed(count)` asks the person, and only a yes forces the delete.
|
|
76
|
+
* A file already gone (404) is `gone` — the caller just refreshes. Any other
|
|
77
|
+
* failure is thrown.
|
|
78
|
+
*/
|
|
79
|
+
export async function deletePermanently(
|
|
80
|
+
assets: TrashAssetsApi,
|
|
81
|
+
fileId: string,
|
|
82
|
+
confirmStillUsed: (uses: number) => Promise<boolean>,
|
|
83
|
+
): Promise<PermanentDeleteOutcome> {
|
|
84
|
+
try {
|
|
85
|
+
await assets.delete(fileId);
|
|
86
|
+
return 'deleted';
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (errorStatus(error) === 404) return 'gone';
|
|
89
|
+
const uses = remainingUses(error);
|
|
90
|
+
if (uses === null) throw error;
|
|
91
|
+
if (!(await confirmStillUsed(uses))) return 'cancelled';
|
|
92
|
+
await assets.delete(fileId, { force: true });
|
|
93
|
+
return 'deleted';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Outcome of {@link restoreFromTrash}. */
|
|
98
|
+
export type RestoreOutcome = 'restored' | 'not_in_trash' | 'gone';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Bring a file back from trash. One that is no longer in trash — restored
|
|
102
|
+
* elsewhere, or blocked after a claim (409 `FILE_NOT_IN_TRASH`) — or no longer
|
|
103
|
+
* exists (404) is reported, not thrown: the trash list is just stale.
|
|
104
|
+
*/
|
|
105
|
+
export async function restoreFromTrash(
|
|
106
|
+
assets: TrashAssetsApi,
|
|
107
|
+
fileId: string,
|
|
108
|
+
): Promise<RestoreOutcome> {
|
|
109
|
+
try {
|
|
110
|
+
await assets.restore(fileId);
|
|
111
|
+
return 'restored';
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (errorStatus(error) === 404) return 'gone';
|
|
114
|
+
if (errorStatus(error) === 409 && errorCode(error) === 'FILE_NOT_IN_TRASH') {
|
|
115
|
+
return 'not_in_trash';
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Per-file outcome of removing several files from the library. */
|
|
122
|
+
export interface LibraryRemovalResult {
|
|
123
|
+
trashed: string[];
|
|
124
|
+
deleted: string[];
|
|
125
|
+
failed: string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove several files from the library at once: each goes to trash, except a
|
|
130
|
+
* blocked one, which is deleted (asking about remaining uses as
|
|
131
|
+
* {@link deletePermanently} does). One failure never stops the others.
|
|
132
|
+
*/
|
|
133
|
+
export async function removeFromLibrary(
|
|
134
|
+
assets: TrashAssetsApi,
|
|
135
|
+
files: ReadonlyArray<Pick<FileMetadata, 'id' | 'status' | 'filename'>>,
|
|
136
|
+
confirmStillUsed: (uses: number, filename: string) => Promise<boolean>,
|
|
137
|
+
): Promise<LibraryRemovalResult> {
|
|
138
|
+
const result: LibraryRemovalResult = { trashed: [], deleted: [], failed: [] };
|
|
139
|
+
const toTrash = files.filter((file) => libraryRemovalFor(file) === 'trash');
|
|
140
|
+
const toDelete = files.filter((file) => libraryRemovalFor(file) === 'delete');
|
|
141
|
+
|
|
142
|
+
const trashed = await Promise.allSettled(toTrash.map((file) => assets.trash(file.id)));
|
|
143
|
+
trashed.forEach((settled, index) => {
|
|
144
|
+
(settled.status === 'fulfilled' ? result.trashed : result.failed).push(toTrash[index].id);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// One at a time: each may ask the person a question, and two questions at
|
|
148
|
+
// once would stack.
|
|
149
|
+
for (const file of toDelete) {
|
|
150
|
+
try {
|
|
151
|
+
const outcome = await deletePermanently(assets, file.id, (uses) =>
|
|
152
|
+
confirmStillUsed(uses, file.filename),
|
|
153
|
+
);
|
|
154
|
+
// `cancelled`: the person kept it; nothing to report.
|
|
155
|
+
if (outcome !== 'cancelled') result.deleted.push(file.id);
|
|
156
|
+
} catch {
|
|
157
|
+
result.failed.push(file.id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
2
|
import type { Asset, AssetUploadProgress, AssetLink } from '@oxy.so/core';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Whether a link is the given application's use. The Oxy API names the using
|
|
6
|
+
* application by ID (`applicationId`, mirrored in `app` since ADR 0043 D4);
|
|
7
|
+
* a link added locally before the server answered may still carry the name
|
|
8
|
+
* the caller passed in `app`. Either matches.
|
|
9
|
+
*/
|
|
10
|
+
function linkIsApp(link: AssetLink, app: string): boolean {
|
|
11
|
+
return (link.applicationId ?? link.app) === app || link.app === app;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sameLink(link: AssetLink, app: string, entityType: string, entityId: string): boolean {
|
|
15
|
+
return linkIsApp(link, app) && link.entityType === entityType && link.entityId === entityId;
|
|
16
|
+
}
|
|
17
|
+
|
|
4
18
|
interface AssetState {
|
|
5
19
|
// Asset data
|
|
6
20
|
assets: Record<string, Asset>;
|
|
@@ -145,7 +159,8 @@ export const useAssetStore = create<AssetState>((set, get) => ({
|
|
|
145
159
|
// Check if link already exists
|
|
146
160
|
const existingLink = asset.links.find(
|
|
147
161
|
(l: AssetLink) =>
|
|
148
|
-
l.
|
|
162
|
+
sameLink(l, link.applicationId ?? link.app, link.entityType, link.entityId) ||
|
|
163
|
+
sameLink(l, link.app, link.entityType, link.entityId),
|
|
149
164
|
);
|
|
150
165
|
|
|
151
166
|
if (existingLink) return state;
|
|
@@ -154,6 +169,7 @@ export const useAssetStore = create<AssetState>((set, get) => ({
|
|
|
154
169
|
...asset,
|
|
155
170
|
links: [...asset.links, link],
|
|
156
171
|
usageCount: asset.links.length + 1,
|
|
172
|
+
inUse: true,
|
|
157
173
|
};
|
|
158
174
|
|
|
159
175
|
return {
|
|
@@ -171,15 +187,16 @@ export const useAssetStore = create<AssetState>((set, get) => ({
|
|
|
171
187
|
if (!asset) return state;
|
|
172
188
|
|
|
173
189
|
const filteredLinks = asset.links.filter(
|
|
174
|
-
(link: AssetLink) =>
|
|
175
|
-
!(link.app === app && link.entityType === entityType && link.entityId === entityId),
|
|
190
|
+
(link: AssetLink) => !sameLink(link, app, entityType, entityId),
|
|
176
191
|
);
|
|
177
192
|
|
|
178
193
|
const updatedAsset = {
|
|
179
194
|
...asset,
|
|
180
195
|
links: filteredLinks,
|
|
181
196
|
usageCount: filteredLinks.length,
|
|
182
|
-
|
|
197
|
+
// The last link going never trashes the file (ADR 0043 D5): it stays
|
|
198
|
+
// where it is and is only no longer in use.
|
|
199
|
+
inUse: filteredLinks.length > 0,
|
|
183
200
|
};
|
|
184
201
|
|
|
185
202
|
return {
|
|
@@ -237,17 +254,14 @@ export const useAssetStore = create<AssetState>((set, get) => ({
|
|
|
237
254
|
getAssetsByApp: (app: string) => {
|
|
238
255
|
const { assets } = get();
|
|
239
256
|
return Object.values(assets).filter((asset) =>
|
|
240
|
-
asset.links.some((link: AssetLink) => link
|
|
257
|
+
asset.links.some((link: AssetLink) => linkIsApp(link, app)),
|
|
241
258
|
);
|
|
242
259
|
},
|
|
243
260
|
|
|
244
261
|
getAssetsByEntity: (app: string, entityType: string, entityId: string) => {
|
|
245
262
|
const { assets } = get();
|
|
246
263
|
return Object.values(assets).filter((asset) =>
|
|
247
|
-
asset.links.some(
|
|
248
|
-
(link: AssetLink) =>
|
|
249
|
-
link.app === app && link.entityType === entityType && link.entityId === entityId,
|
|
250
|
-
),
|
|
264
|
+
asset.links.some((link: AssetLink) => sameLink(link, app, entityType, entityId)),
|
|
251
265
|
);
|
|
252
266
|
},
|
|
253
267
|
|
|
@@ -262,10 +276,7 @@ export const useAssetStore = create<AssetState>((set, get) => ({
|
|
|
262
276
|
const asset = assets[assetId];
|
|
263
277
|
if (!asset) return false;
|
|
264
278
|
|
|
265
|
-
return asset.links.some(
|
|
266
|
-
(link: AssetLink) =>
|
|
267
|
-
link.app === app && link.entityType === entityType && link.entityId === entityId,
|
|
268
|
-
);
|
|
279
|
+
return asset.links.some((link: AssetLink) => sameLink(link, app, entityType, entityId));
|
|
269
280
|
},
|
|
270
281
|
|
|
271
282
|
// Reset store
|